90 Commits

Author SHA1 Message Date
cproudlock
2ec097069c fix(shopfloor): S: mapper review fixes - headless task, drift guard, pre-flight, re-seed
Independent review in the win11 VM found four things worth fixing before
the fleet sync:

- The task action ran powershell -WindowStyle Hidden, which on Win11
  (Windows Terminal as console host) flashes a window and steals
  foreground for ~0.5 s on every fire. Action is now
  conhost.exe --headless powershell.exe ..., verified silent.
- If the share copy of Map-SfldShare.ps1 changes without the manifest
  DetectionValue being bumped, the engine calls the registrar every
  cycle. The registrar now takes an idempotent fast path (staged hash
  matches, task present with our action -> re-stamp and exit, no task
  churn) and logs one WARN per hash when -ExpectedHash disagrees with
  the share file.
- Replacing a wrong or unreadable mapping now pre-flights the new
  mapping on a spare letter and keeps the old S: when that fails
  (except on 1219, where the old session is what blocks the new one).
- Credential Manager is re-seeded whenever the HKLM credential changes
  (password rotation) and after every repair, instead of once per
  profile. Set-State no longer wipes the stored hash on suspect writes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011boHjE3EeexwszdiKDZ7iX
2026-09-04 13:37:37 -04:00
cproudlock
ca9b0de89c feat(shopfloor): Repair-AppShortcuts.ps1 for Defect Tracker pins after MSI upgrade
An MSI major upgrade only fixes the shortcuts it owns. The copies we
place (Shopfloor Tools folder, per-user taskbar pin) are advertised
shortcuts carrying the old ProductCode, so they stop resolving on
locked-down bays where the operator cannot unpin. Delete the stale copy
and write a plain shortcut to the real exe, read back and verified;
MSI-owned advertised shortcuts are left for MSI self-repair.

Wired on the SFLD share as a common manifest entry (Type=PS1, Always,
same PCTypes as the WJF Defect Tracker install, ordered after it).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011boHjE3EeexwszdiKDZ7iX
2026-09-04 12:51:23 -04:00
cproudlock
01cf99da01 fix(shopfloor): self-healing S: drive mapper to stop mid-shift drive loss
S: was mapped once per logon (HKLM\Run -> Map-SfldShare.ps1) with no
re-check, so any SMB session break the redirector could not silently
re-authenticate left S: dead until the user logged out and back in.

Replace the Run entry with a scheduled task (BUILTIN\Users, Limited)
that fires at logon, every 3 min, on unlock/console-connect, and on
network-connect. The mapper now probes S: with a real timed directory
read (not net use output parsing, which reports a dead drive as
healthy), only repairs after two consecutive failed probes with TCP 445
reachable, and never touches a working drive. Credentials go through
New-SmbMapping / CredWrite instead of the net use command line, and a
Domain Password credential is seeded so Windows' own reconnect and UNC
access share one identity (kills the 1219 conflict class).

Register-MapSfldShare stamps HKLM\SOFTWARE\GE\SfldShare\MapperHash with
the staged script's SHA256 after success, so GE-Enforce can (re)register
it fleet-wide via a common manifest entry and re-fire on version bumps.

Verified in the win11 VM: healthy no-op with an open handle held across
runs, repair of deleted / wrong-target / present-but-unreadable drives,
unaided recovery after a NIC bounce and on the 3-min interval, and the
full SYSTEM-from-share registrar path flipping detection to installed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011boHjE3EeexwszdiKDZ7iX
2026-09-04 12:18:33 -04:00
cproudlock
47f5d4603a fix(e10): --e10 passed bare silently skipped the controller
Three bugs, all found by actually running the thing rather than reading it.

1. `--e10` with no value did nothing. Making the flag optional-valued gave it
   an empty-string constant, and the code deciding whether to drive the
   controller used a truth test - so the documented `--e10` form printed the
   full translation and quietly skipped the hardware. No error, because
   nothing failed. Now gated on `is not None`, with a comment saying why.

2. A missing part program dumped a traceback. The top-level handler tidied
   e10 errors and re-raised everything else. Now a clean message that lists
   the part programs it can actually see, which also covers Windows path
   confusion.

3. Errors could vanish when piped. stdout is block-buffered and stderr is
   not, so the message printed before the output it referred to and a `| tail`
   dropped it entirely - looking like a silent failure. stdout is flushed
   before writing an error.
2026-08-27 13:55:00 -04:00
cproudlock
5e249e8ad0 feat(e10): run on Windows as well as Linux
Serial access moves behind open_port(), with a termios backend on POSIX and a
Win32 backend reached through ctypes. Deliberately no pyserial: these tools
have to run on locked-down, air-gapped shopfloor machines where installing a
package is a project of its own.

Verified on a real controller from both a Linux laptop and a Windows 11 PC.

Three things that made this harder than it should have been, all now handled:

  - CreateFileW returns a HANDLE, which is 64-bit. Without an explicit restype
    ctypes truncates it to c_int. The open then appears to succeed and every
    read and write silently operates on a bogus handle, which looks exactly
    like a dead cable. Every kernel32 call now declares argtypes and restype.

  - Defaulting to a fixed COM number was actively harmful. A wrong-but-real
    port opens without error and answers nothing, so defaulting to COM3 while
    the adapter was on COM4 produced the same silent symptom. Windows now
    auto-picks only when exactly one port looks like a USB virtual COM port,
    and otherwise refuses and lists what it found.

  - COM10 and above need the \\.\ prefix, which is harmless on COM1-9, so it
    is always applied.

Adds two diagnostics, because "no response" has too many possible causes to
guess at:
  ports    every serial port that actually exists. On Windows this reads
           SERIALCOMM from the registry, so a port absent here does not exist
           now, whatever Device Manager showed last week.
  doctor   walks the layers - discovery, open, write, read - and reports which
           one fails, including a warning when the configured port is an
           onboard one while a USB adapter is present.

Port selection is -d, then $E10_PORT, then the safe auto-pick. Friendlier
errors on both platforms for a missing port or a permissions problem.
2026-08-27 13:40:15 -04:00
cproudlock
eb4d067f12 feat(e10): SIC e10 part marker client, pattern generator and eDNC simulator
Groundwork for replacing a Telesis pinstamp with a SIC Marking e10 while
keeping the existing eDNC/MarkTelesis operator workflow. MarkTelesis has a
hardcoded marker-type enum and eDNC is serial-only, so the plan is a
translator that impersonates a Telesis controller on a COM port. These are
the pieces that translator will be built from, all verified against a real
e10 (a P63, firmware "E10 P63 (20140924)").

scripts/e10-client.py
  e10-V6 protocol client. Marking path and file transfer both use the BINARY
  protocol: the text protocol uses space as its delimiter, so it cannot carry
  a field cleared to a single space nor the space runs in real marked text,
  and it has no checksum. Includes fault decoding against the manual's fig 1,
  and a guard that refuses to overflow a variable's declared buffer.

scripts/make-e10-pattern.py
  Builds marking files with DECLARED variables. The controller's own protocol
  cannot create one: NEWFILE/INSERTTEXTLINE/SAVEFILE writes the reference but
  leaves the variable count at zero, and SETVAR then fails. Template-based,
  copying through byte-for-byte everything the format notes do not explain.

scripts/simulate-ednc.py
  Parses a part program, substitutes tokens and emits the byte-exact frames
  MarkTelesis would send, then translates them for the e10 and can drive one.
  The wire format came from disassembling MarkTelesis.exe rather than from a
  capture: frame builder at 0x401cf0, and the type letters at their call
  sites. Note there is no station ID, unlike the Backup470 dialect.

scripts/fake-telesis.py
  Answers as a Telesis controller so MarkTelesis will hold a conversation
  long enough to be recorded, with switches to force NAK and timeout paths.

reference/e10/
  52-file snapshot off the bench controller, used as the corpus for reversing
  the MARK_V5.0 format and for testing without hardware. ConfigCtrl is
  excluded and gitignored - it stores the supervisor password in cleartext.
  GE part programs are excluded for the same reason.

Three hardware findings worth knowing:
  - The controller TRUNCATES SILENTLY to a variable's declared buffer size
    while SETVAR still answers ok, and GETVAR echoes the full value back so
    it cannot detect this. Guarded client-side against the size in the file.
  - It refuses to mark when a referenced variable is unset, which makes an
    unset field fail loudly rather than marking the previous part's data.
  - Setting variables does not modify the stored file, so backups will not
    churn on every marked part.
2026-08-27 12:57:20 -04:00
cproudlock
f1755751bb Part marker: a misconfigured bay is now loud, and the listing loop is bounded
Every failure of Get-TelesisPortConfig was filed as "no Telesis marker
configured on this PC" and exited 0. That is correct for the many PCs which are
not marker bays, and wrong for a bay whose registry key EXISTS and could not be
read: a half-configured marker was silently never backed up again, which is
exactly the failure shape this feature was built to prevent. The absent-key case
now throws a distinguishable exception and stays quiet; anything else is logged
every cycle and counted as a skip.

Get-TelesisFileList was unbounded. The receiver drives that loop, so a
controller repeating a name or answering noise on a marginal cable would spin
for ever - and not harmlessly, because it holds the MARKING port while
GE-Enforce waits on the script with no timeout, so one chatty controller wedges
the enforcement cycle and the operator cannot mark a part. Now capped by count,
by elapsed time, and by repeat detection, all of which THROW: a partial listing
presented as a complete snapshot would be worse than failing.

Baud and Data Bits parse with TryParse. Note the narrower reason than it looks:
[int]'' is 0 in PowerShell, so empty and non-numeric values were already handled
by the zero-check. What was not handled is a numeric string too large for Int32,
which throws on the cast and would have been filed as "not a marker bay".

Verified in the win11 VM as SYSTEM, against both the old and new module: absent
key stays quiet, a key without Port Id is loud, garbage and out-of-range baud
both fall back to 9600, a real 19200 is still honoured, a repeating controller
stops at once and endless unique names stop at the cap.
2026-08-26 15:06:47 -04:00
cproudlock
8ba86ba560 Write through the mapped drive, record the UNC
The snapshot path stored against a marker read "W:\backup\partmarker\...". That
mapping exists only inside the SYSTEM logon session, only while the GE-Enforce
cycle is running, and only on that one bay - so the path recorded in ShopDB
resolved to nothing for the person most likely to need it.

Writing still goes through the drive letter, deliberately: that is the
connection GE-Enforce authenticated, and reaching the same server by another
name costs a second authentication or fails outright with error 1219. Only the
recorded path changes.

Resolving a mapped drive to its share needs all three of DisplayRoot, Root and
the WMI ProviderName, because which one is populated depends on how the drive
was made: net use mappings (what GE-Enforce creates) fill DisplayRoot, while a
New-PSDrive to a UNC leaves DisplayRoot EMPTY and puts the share in Root. A
local drive and an already-UNC path both fall through untouched.
2026-08-25 14:58:41 -04:00
cproudlock
f99ece1366 Make an unknown argument an error, not a shrug
The script had no CmdletBinding, so PowerShell dropped unrecognised named
arguments into $args instead of failing. A typo in a manifest entry Args -
"-WindowStartMinutes 45" - would have been ignored silently, and the capture
would have run at the wrong time with nothing in the log to explain it. That
is exactly how an hour went into debugging a time gate that was working.

Also adds WindowStartMinute so the window can open at :45 rather than on the
hour. A shift change is a moment, not an hour, and the first run of the day is
easiest to watch when it lands when somebody expects it.
2026-08-25 14:37:00 -04:00
cproudlock
ba4df185cd Put the marker entries where the share actually looks
Both entry files described the wrong scope. Checking the live share mirror:

Backup-PartMarker.ps1 belongs in common\scripts, not the partmarker scope,
because it imports ..\lib\TelesisMarker.psm1 and ..\lib\ShopdbBackupClient.psm1
and those live in common\lib. Backup-NtlarsSettings.ps1 sits there for the same
reason, and its entry already lists gea-shopfloor-partmarker in PCTypes -
scoping is by PCTypes, not by which directory the entry lives in. As written it
would have failed its imports on the first cycle.

eMxInfo healing is a NEW entry in the partmarker scope rather than a PCTypes
edit: those entries live in the collections and nocollections scopes, which a
marker bay never evaluates. The site already duplicates the config and the cmd
per scope, so markers follow that pattern - at the cost of the yearly rotation
now touching three copies, which the entry says out loud.

The cmd template also carried a stale source path. The deployed cmd reads
..\configs\eMxInfo.txt; the repo template still said the file sat beside it,
which would have exited 1 on every run.
2026-08-25 14:01:26 -04:00
cproudlock
bd131f6da9 Diagnose a share that prompts, when the same UNC path works
Shopfloor PCs intermittently lose S: and ask for credentials while navigating
the identical UNC path in Explorer works fine. Four different causes produce
that same appearance - a persistent reconnect that raced the network, a stored
credential whose target string does not cover the name form in use, two
identities contending for one server, or a password that rotated - and each
needs a different fix. From the desk they are indistinguishable.

Test-ShareAccess.ps1 collects what separates them: reachability of both name
forms, the mappings and the account each remembers, the exact credential target
strings, Kerberos tickets, live SMB sessions and the identities holding them,
and the SMBClient/Connectivity log. Then it names the causes the evidence
supports rather than leaving a wall of output to interpret.

Read-only by construction: it maps nothing, deletes nothing, stores no
credential, and prints usernames and a password-present flag but never a
password, because its log lands where techs read it. Safe on a bay while
somebody is working.

It reports which identity it is running as, because mappings and SMB sessions
are per logon session: run as the user to explain S:, as SYSTEM to explain W:
and the backup scripts. A drive missing in one is genuinely absent in the
other, and mistaking that for a fault is easy.

Also derives the marker backup's share root from the script's own location
rather than a hardcoded UNC. GE-Enforce mounts the share as W: with SFLD
credentials, and reaching the same server by a different name than the mount
used costs a second authentication - or fails outright with 1219 - for reasons
the error text never mentions. The script's own path is the one handle
guaranteed to name the connection that already exists.
2026-08-25 13:44:05 -04:00
cproudlock
18168b54f8 Say why a share write failed, because the symptom lies
This runs as SYSTEM, which authenticates to the network as the machine account.
A SYSTEM process reaching a UNC path without credentials gets an access-denied
that Test-Path reports as "not found", so a permissions problem reads as a
missing directory and sends the next person hunting the wrong thing. The
backups registry already carries that warning; the client had not acted on it.

On a write failure the log now names the likely cause, and the two conditions
that make it work: the entry must run under the GE-Enforce cycle, since the
share is mounted only for its duration and all SYSTEM processes share the logon
session that holds the connection, and the share ACL has to admit computer
accounts.

Verified against an unreachable UNC on a bay with a real controller: capture
succeeded, the write failed, the diagnostic was logged and the cycle was
counted as a missed backup.
2026-08-25 13:32:23 -04:00
cproudlock
b228a69364 Identify a marker by its device, without moving what NTLARS reports
A part marker has no machine number of its own - the device is what a backup
belongs to, and C:\Enrollment\asset-id.txt is what names it. Reading that file
inside the shared machine-number lookup looked like the tidy place for it and
was wrong: marker bays run eDNC too, so Backup-NtlarsSettings.ps1 reaches the
same lookup, and it would have started posting a machine's DNC settings under
the marker's asset number where today it exits quietly. The marker script asks
for the fallback explicitly instead, which is what -MachineNumberFallback is
for.

Also from review:

Returning a byte array bare lets the pipeline enumerate it, so an EMPTY file
came back as $null, ComputeHash threw, and with ErrorActionPreference=Continue
the manifest entry silently kept the previous file's hash - a corrupted
manifest stored as a good revision. Empty parameter files are plausible.

Reading registry values directly under StrictMode throws when one is absent, so
the "no Port Id" guard was unreachable and a partially configured marker bay
reported itself as having no marker at all, permanently and quietly. Values are
now read through a presence check with the fleet's own defaults.

The outside-window quiet state embedded the hour, so the say-once module said
it hourly. Command replies now have their checksum verified like data blocks
do, since a corrupted listing entry would otherwise be trusted and a file
requested under a name the controller does not have. A controller that
genuinely holds nothing is no longer reported as a failed capture. A snapshot
that could not be written to the share now counts as a missed backup like the
rest, and the header no longer claims orphaned snapshots get tidied - they are
kept on purpose, because a timed-out post may still have been recorded.
2026-08-25 13:21:45 -04:00
cproudlock
abbdc9873c Heal eMxInfo.txt on marker bays too
The eMxInfo entry on the share is scoped to collections and nocollections, so
a marker bay never heals. Marker bays run eDNC as well - 01-eDNC.ps1 installs
it before the Mark MSI - so the same vendor installer clobbers the same file
there, with nothing to restore it.

The fix is a scoping change rather than a new mechanism: add the marker PCType
to the existing entry. The paths it already writes are the right ones, because
mxTransactionDll.dll in the Mark overlay reads \Dnc\Server Files\eMxInfo.txt -
the Mark suite uses the DNC install, not its own directory.

The cmd variant additionally refreshes the copy that imaging drops into the
Mark directory. Nothing reads that path, so it is cosmetic; it is there so a
healed bay and a freshly imaged one are identical, rather than differing in a
way that costs somebody an afternoon later.
2026-08-25 13:10:29 -04:00
cproudlock
280a3b3ba4 Add the manifest entry that puts the marker backup on the fleet
Scoped to gea-shopfloor-partmarker so it reaches marker bays only. The script
checks the DNC\Mark registry itself as well, so a mis-scoped entry exits
quietly on an ordinary PC rather than erroring every five minutes.

The entry records what the share layout has to provide, because the script
resolves its two modules relative to its own location and one of them,
ShopdbBackupClient.psm1, is shared with the NTLARS backup and has changed. It
also records the schedule and the settings a bay needs, since all three are
places where a deployment can look successful and quietly do nothing.

Also counts a failed capture as a missed backup, not just a busy port. Port
contention resolves itself; a controller that will not answer does not, and
the two are worth telling apart in the count that reaches ShopDB.
2026-08-25 13:09:17 -04:00
cproudlock
e40a294193 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.
2026-08-25 13:02:06 -04:00
cproudlock
12c6c771dc Stage the two universal print drivers at imaging time
A bay comes off the line unable to build a printer queue. Set-ShopdbPrinters.ps1
creates queues against drivers that are already present and skips any that are
not, so a freshly imaged PC silently gets no printers until some later
enforcement cycle tops it up.

The two universals are 100 MB and resolve the overwhelming majority of the
fleet's printers. The four device-specific drivers are another 126 MB and are
deliberately NOT here - they belong only on bays that own that hardware, and
those bays pick them up from the runtime manifest.

NO WAVE GATE on these entries, unlike the runtime ones. Waves exist because ~300
bays share one runtime share and GE-Enforce spreads them across only five
minutes; imaging runs a handful of bays at a time on the isolated PXE LAN that
already moves multi-GB WIMs, so there is no stampede to prevent.

DETECTION IS THE SPOOLER'S OWN KEY, not pnputil, and the difference is not
cosmetic. Installing a printer driver is two steps: pnputil /add-driver stages
the package into the driver store, Add-PrinterDriver registers it with the
spooler. Get-PrinterDriver - which is what Set-ShopdbPrinters consults - reads
the spooler. Measured on a Win11 bay: after Remove-PrinterDriver the spooler
reports the driver gone while pnputil still lists the package, so a pnputil-
detected entry would report compliant forever while every queue kept skipping. A
broken bay that looks healthy is the worst outcome available here.

The payload is not in git, matching every other installer under preinstall/:
stage printer-drivers/ onto the PXE share alongside the rest.
2026-08-19 17:16:38 -04:00
cproudlock
d479e6823c Clear comments orphaned by today's unattend removals
The EAP-PEAP and Office comments outlived the entries they labelled, which
would send the next reader looking for commands that are not there. The
surviving wireless comment said 'Wi-Fi profiles' plural when only
INTERNETACCESS remains; it now records why the other two went and that
wlansvc has to be started first.
2026-08-19 16:07:37 -04:00
cproudlock
ad075c255c Actually deploy INTERNETACCESS, and skip the OOBE network page
INTERNETACCESS has never been deployed. wlansvc ships Manual/Stopped on
Windows 11, nothing in the unattend started it, and `netsh wlan add profile`
refuses to run without it:

    The Wireless AutoConfig Service (wlansvc) is not running.
    exit=1

Reproduced in the win11 VM. So specialize was adding no wireless profile at
all, and every bay reached OOBE with no known network - which is exactly the
wifi/network selection the tech has been clicking through.

Fixed by starting the service first. `net start`, NOT `sc start`: sc is
asynchronous and returns START_PENDING, and the VM showed netsh losing that
race and still reporting the service as not running. The command is idempotent
and always exits 0, so a machine with no wireless hardware is unaffected.

With the profile genuinely landing, HideWirelessSetupInOOBE is now true.

Dropped EAP-PEAP and BLUESSO from specialize while here. Both are installed by
the MCL ppkg - Install-GEWirelessSettings.ps1, with byte-identical EAP-PEAP.msi
(sha d90f2489045b6fdb) and BLUESSO.xml (sha d8d886db37a40054) - and BLUESSO
could never have bootstrapped anything: hidden SSID, connectionMode=manual,
eapGtc with passwordFromUser. INTERNETACCESS is the bootstrap path and it is
WPA2PSK with connectionMode=auto, so it needs no EAP module and no certificate.

The eight GE certificates at Orders 4-11 are also duplicated by the ppkg (as
Certificates_*.provxml runtime entries) and are left in place for now: that is
a separate cut, and the case for removing them rests on the pre-ppkg token
fetch being publicly signed, which is true today (mcl.dwcdn.geaerospace.com is
issued by Google Trust Services) but is not ours to depend on.

lint-unattend.py passes clean on all three live files and all three repo copies.
2026-08-19 16:04:56 -04:00
cproudlock
6e8b8a83a7 Deploy the site config bays actually read, and close the drift gate
The shortcut fix from e844ff3 has been live in git and absent from the floor
since 2026-08-06. site-config.json exists TWICE on the share, from one repo
source: enrollment/shopfloor-setup/ and enrollment/config/. Only the second one
is staged to a bay - startnet copies Y:\config\site-config.json to
W:\Enrollment\site-config.json - and it was the stale one.

So every bay imaged in the last two weeks came up without the Plant Apps startup
item and without the Defect_Tracker taskbar pin, while the drift report showed
site-config.json in sync, because it was reading the copy nothing consumes. A
green check on the wrong file is worse than no check.

Deployed the repo copy over it (backup on the server at
~/backups/site-config.json.bak-20260819) and marked BOTH destinations git-owned,
so neither can go stale behind the other.

The shopfloor unattend is reconciled the other way round. Live was 87 lines ahead
of the repo - the default-user startup-delay removal, the Windows Update
disables, the removable-media block that stops PPKG auto-detection at OOBE, and
the run-enrollment.ps1 path fix from C:\ to C:\Enrollment. The repo copy was a
201-line fossil. LIVE WINS: these files boot machines, and pushing the repo copy
over them is exactly the 2026-08-06 outage that prompted this tool. Adopted live
into the repo verbatim (lint clean) rather than merging by hand.

Also fixed a pair that could never pass: the engineer unattend was compared
against playbook/FlatUnattendW10.xml, which is the STANDARD answer file, so it
reported DIFFERS permanently. 6f86c81 added FlatUnattendW10-engineer.xml but did
not repoint the pair at it. A permanently red row is one nobody reads, which is
how the site-config gap stayed invisible next to it.

All twelve pairs are now git-owned and in sync, and the gate has nothing left
classified as "known bad" to hide behind.
2026-08-19 15:41:46 -04:00
cproudlock
6f86c81a44 Stop installing Zscaler twice, and reconcile the unattends with the share
Zscaler: Install-Zscaler.ps1 ships in all three MCL packages with
Zscaler-windows-4.7.0.317-installer-x64.msi bundled. It is tenant-aware
(geaerospace.com for GCCH, geaerospaceglobal.com for RoW, gegovdev.us for QA)
and passes enableFips=1.

Our FirstLogonCommands entry installed 4.5.0.337, two minor versions older,
without enableFips, and with userDomain hardcoded to geaerospace.com - wrong
for a RoW bay, which startnet's tenant sub-menu can produce. Two msiexec runs
against the same product at one logon also risk 1618. Removed, same reasoning
as Office.

THE BIGGER FIND, while removing it: this repo template was badly out of date
with the share, and the playbook deploys it with force: yes.

Missing here but present live: specialize Orders 39-48 - the bulk-enrollment
orchestrator registration (Order 39, Register-BulkEnrollTask-Standard.ps1), the
Default User startup-delay and Start-menu tweaks, the two NoAutoUpdate keys,
the 'vanilla' Shift+F10 fallback alias, and the removable-media block that stops
the PPKG being auto-detected at OOBE. Also missing: FirstLogonCommands Orders
4-10 (wuauserv disable, Default User hive edits, removable-media unblock).

A playbook run would therefore have stripped enrollment from every gea-standard
and gea-engineer bay. Both templates are now reconciled against the live share.

Engineer gets its own template. The two files differ only by FirstLogonCommands
Order 11, which installs the Engineering Layer - so deploying one shared file to
both, as the playbook did, silently removed that entry from gea-engineer on
every run. Split into FlatUnattendW10.xml and FlatUnattendW10-engineer.xml with
an explicit per-type loop.

lint-unattend.py passes clean on all three live files and all three repo copies.
2026-08-19 15:23:44 -04:00
cproudlock
2415e6cfd6 Drop the first-logon shutdown abort that never aborted anything
FirstLogonCommands Order 1 was `shutdown -a`, and it protected nothing.

The reboot it was aimed at is the one provtool schedules when the provisioning
package is applied - and Start-BulkEnrollOrchestrator.ps1 already aborts that
itself, on the line straight after provtool returns, with its own `shutdown /a`
and the comment "Abort the reboot that the provisioning package scheduled".
Our copy runs at logon, before the orchestrator has applied the package, so at
the moment it executes there is nothing pending to abort.

The reboot that actually interrupts first-logon work is the orchestrator's own
`shutdown /r /t 0 /f` at the end of its run. That one is immediate, so it has
no pending-shutdown window for `shutdown /a` to cancel, and it is deliberate -
the Entra ID join needs it.

So the entry was a no-op that read like protection. It cost real time this week:
its presence was taken as evidence that first-logon work was being guarded when
it was not.

Removing it changes no behaviour. What it was reaching for - first-logon work
surviving a mid-logon reboot - needs resumability instead, which is what the
retired Office payload now does and what zscaler.bat still lacks.

Order 2 (zscaler) is left numbered 2; FirstLogonCommands execute in ascending
Order and gaps are legal, so renumbering would be churn for no gain.

Live gea-standard and gea-engineer updated to match. lint-unattend.py passes
clean on all three live files and both repo copies. gea-shopfloor never had
this entry.
2026-08-19 15:16:25 -04:00
cproudlock
f3df6c7175 Stop installing Office twice on standard and engineer
Install-Office.ps1 ships inside all three MCL provisioning packages
(GCCH_Prod_MCL-PERS v2.15, -SH v2.10, -LOAN v1.3 and the RoW equivalents). It
moves M365 Office x86.zip out of C:\Deploy\Applications\BPRT, extracts it to
C:\WCDApps\Office and runs setup.exe /configure against the vendor's own
Configuration-x86.xml.

The unattend's FirstLogonCommands Order 3 was a second ODT run at that same
first logon. Click-to-Run is single-instance, so the two conflicted - which is
what "Office is not installing properly" looked like on both image types.

They also disagreed on what to install. The vendor package uses
O365ProPlusRetail, excludes Access/Groove/Lync/OneDrive/Teams, matches the OS
language, carries RemoveMSI and AppSettings, sets SharedComputerLicensing=1 for
the SH purpose and drops Outlook for LOAN. Ours used O365ProPlusNoTeamsRetail,
en-us, no RemoveMSI, no purpose awareness, and hardcoded
SharedComputerLicensing=0 - which would have broken shared computer activation
on every SH machine.

Removing the entry here matters as much as removing it live: the playbook
deploys this file to both image types with force: yes, so a playbook run would
have put the duplicate straight back.

The share payload is kept but retired, with a README recording why and what to
check before ever re-enabling it. Its install.bat and gea.xml were repaired
first (stage out of C:\Deploy ahead of the orchestrator's DeleteDeployDir task,
pin the ODT build to match v32.cab so no CDN lookup is attempted on the
isolated LAN, and register a self-deleting resume task since FirstLogonCommands
run only once) - VM-tested, then left in place unreferenced.

Live gea-standard and gea-engineer unattends updated to match; lint-unattend.py
passes clean on all three live files and both repo copies.
2026-08-19 15:13:32 -04:00
cproudlock
26d14b0684 Key the MTConnect runbook on machine numbers, not hostnames
The first draft identified every bay by PC name, taken from a 12 June map and
the machine list's Shop Floor PC Name column. Both are hand-built snapshots: a
PC that has since been replaced or re-imaged answers to a different name while
the machine it sits at has not moved. Following those names would eventually
mean renaming the wrong bay.

Machine number leads every table now, hostnames are marked as "where the fault
was last seen", and the reader is pointed at what the bay itself knows - the
eDNC registry first, then the enrollment file, then UDC - with ShopDB as the
live PC-to-machine record since the collector maintains it.

Also notes that either CSV should be generated from ShopDB rather than
hand-maintained, for the same reason.
2026-08-11 14:46:52 -04:00
cproudlock
cc20501e2e Runbook for the MTConnect follow-up work
The rename fix only changes what future renumbers do. Nine bays are already in
the state it prevents, the 3.25.3 adapter is staged but not on the SFLD share,
and two questions - whether DMG Mori needs a bundle at all, and whether the v2
manifest ever received the MTConnect entries - are unanswered.

All of it needs a machine the dev box cannot reach, so it is written as steps
to follow rather than something to run here: exact hashes to check before
copying, per-bay numbers from the hostname map, verification after each change,
and rollback for the share copy.

Notes the two things not proven on real hardware: that stopping the Makino
Adapter Manager keeps it from rewriting config on shutdown, and that the four
Fanuc bays with no agent device name are a different fault that a rename would
not fix.
2026-08-11 14:44:08 -04:00
cproudlock
0d9c4fdac4 Rename every copy of an MTConnect device name, not just the agent's
Set-MachineNumber rewrote one Devices.xml per variant and restarted the agent.
That is right for Okuma and eDNC, which keep the device name in that one file -
checked across 7 Okuma and 15 eDNC bay captures. Fanuc and Makino keep it in
several, and editing only the agent's copy leaves the adapter streaming under
the old identity.

It has already happened. Seven of thirty-four captured Fanuc bays have the
agent on the right machine number and the adapter still on the bay it was
imaged from: 4007 on a 7801 Toshulin, 3031 on a 7804, 2005 against 2006 on a
dual-spindle pair. Both captured Makino bays have Devices.xml on the machine
number and the other three files still on MAKINO-1.

Update-MTConnectVariantName renames across a declared set of files with the
owning services stopped first. Makino needs that ordering more than most: the
vendor's guide says the Adapter Manager rewrites the COMPLETE configuration
from memory as it stops, so an edit made while it runs is discarded, and that
the name must be identical in every file or the agent may not start.

It collects every name in play before rewriting rather than discovering one.
A bay half-renamed by the old code carries two at once, and converging on the
one that happened to be found leaves the other behind - which is the state this
is meant to end, not reproduce. Nothing is written if a service will not stop,
and whatever was stopped is started again.

Tested on Windows 11 against copies of the real captures: the 7801 bay (adapter
4007) converges to 7801 in two files and leaves the correct agent alone; the
7502 Makino bay converges all four; second runs report no changes. Service
ordering verified by process id, using stand-in services - sc.exe fakes named
after the real ones are not real services, so the service list is a parameter
defaulting to the production names.

Does not fix the bays already in this state; they need a run each.
2026-08-11 14:28:03 -04:00
cproudlock
54cbe6b5d6 Bring the share's common scripts under version control
Sixteen files that run on every shopfloor PC existed only on the SFLD share.
The cost showed up while debugging the NTLARS backup: the script that posts to
ShopDB could not be read, reviewed or diffed, so its behaviour was inferred
from log output for most of a day. It turned out to hold a silent fallback that
had been governing the whole fleet for months.

Imported as-is from tsgwp00525-v2, no edits:

  lib/ShopdbBackupClient.psm1        the shared backup client
  scripts/Backup-NtlarsSettings.ps1  converted to use it
  scripts/Set-ShopdbCollectorKey.ps1 collector credential delivery
  scripts/Test-RegExport.ps1         exercises the .reg codec with mocks
  scripts/Set-EventSaver*.ps1        kiosk power / screensaver / disable
  scripts/Setup-OpenText.*           OpenText install + toolbar
  scripts/Migrate-PCType.ps1, Select-KioskType.ps1, Set-FmsHostsEntry.ps1,
  scripts/ensure-vnc-firewall.ps1, Install-AcroReader.cmd, Install-Oracle11r2.cmd

lib/Install-FromManifest.ps1 is also updated from the share, which was 37 lines
AHEAD of this repo and purely additive: the Add-EnforceResult reporting added
during the kiosk API cutover, done live and never committed back. Nothing was
removed.

Checked for embedded secrets before committing; there are none.
Set-ShopdbCollectorKey deliberately reads its token from a sibling file on the
share rather than holding it, so the script is safe to track.

The share remains what actually runs. This makes it reviewable, and makes the
next drift visible as a diff rather than a surprise.
2026-08-11 12:36:38 -04:00
cproudlock
b075939c38 GE-Enforce: roll undated logs so retention can actually reach them
The prune drops a *.log by LastWriteTime. A log written to a fixed filename is
appended every cycle, so its LastWriteTime is always now, it can never be older
than any cutoff, and it grows without limit. ntlars-backup.log, eventsaver.log
and shopdb-collector-key.log all do this. On one part-marker PC eventsaver.log
had reached 11,000 lines and ntlars-backup.log 3,234, of which 3,217 were the
same "Throttled" line - the four events that mattered were unfindable without
grep -v.

Undated logs are now rolled to <name>-YYYYMMDD.log before the prune runs, which
stops them being written to and lets them age out on the existing 30 day
retention. The owning scripts need no change: they append with Add-Content or
Tee-Object, which recreate a missing file on the next write. This matters
because those three scripts are live-share artifacts, not in this repo.

Rolled under the date of its FIRST LINE, so the stamp matches the contents.
That is also the only trustworthy signal: CreationTime is not, because NTFS
file tunneling gives a recreated file the old creation time when it reappears
within 15 seconds, so a busy log keyed on that would look stale the instant it
rolled and would roll again every cycle.

A log with no parseable timestamp is left alone until it passes 5MB, so an
unrecognised format still cannot grow forever. Empty files and already-stamped
files are skipped. Rolling onto an existing target appends rather than
overwriting, so a second roll on one day loses neither side.

Verified against real files under pwsh, including the append branch and the
owner recreating the file afterwards. The first attempt keyed on CreationTime
and a second used [datetime]::TryParse with an untyped $null, which throws "no
overload" and would have made the whole roll a silent no-op; the ref is now
pre-typed.
2026-08-11 09:03:28 -04:00
cproudlock
6d5fee786c Proposal: fold in what a day of fixing the current pipeline taught
New section 9a - five design rules, each from a bug fixed on 2026-08-06 and each
cheap to honour in a new design but expensive to retrofit:

  Absent and empty are different. Four scripts tested config as
  "not null AND Count > 0", so an explicitly empty list fell through to a
  hardcoded default - a kiosk configured with "desktopApps": [] was given the
  full machine-tool set. Configuring "none" produced "everything".

  Deny by default. preinstall.json had 16 of 21 entries at PCTypes ['*'] and the
  SMB manifest 18 of 25 unfiltered. Both filters worked perfectly; they just were
  not applied.

  Gate at the point of action. Call-site gates on PC type were bypassed by the
  finalization phase, silently.

  The image carries configuration. WJ Shopfloor.lnk is inside the WIM, so no
  pipeline change removes it.

  The ppkg is a hard boundary. Chrome, RealVNC, Tanium, CyberArk and the rest come
  from the package - thinning the image does not thin them.

Also corrects a premise in section 4.2: a first-boot selection UI cannot assume
the API is reachable, because ShopDB needs the AESFMA wifi SSID and the imaging
LAN has no route to it. Points at Install-ShopdbKiosk-WhenOnline.ps1 as the
reusable shape - arm a task at imaging, do nothing until the API answers, act
once, verify, self-remove.

Plus two risks, the diagnostics collector in the reference map, and a gotchas
section: HardwareDriver.json defeats ConvertFrom-Json on every PowerShell
version, Compress-Archive caps at 2 GB against an 8 GB package, and PowerShell
Core normalises backslashes on Linux so bay scripts can be tested here.
2026-08-06 17:36:17 -04:00
cproudlock
0e1b80b903 preflight: stop ConvertFrom-Json choking on the driver catalogue
The driver check - the most valuable check in this script - has never actually
run. It died on:

  Driver check failed: Cannot convert the JSON string because a dictionary that
  was converted from the string contains the duplicated keys 'FileName' and
  'FileName'

HardwareDriver.json carries both casings of the same fields - fileName and
FileName, destinationDir and DestinationDir. ConvertFrom-Json treats object keys
case-insensitively and refuses the document. -AsHashtable would handle it but
that is PowerShell 6+, and WinPE runs 5.1. Confirmed it throws on PowerShell 7
too, so no version of ConvertFrom-Json can read this file as-is.

Pulls the four needed fields out of each entry by regex instead, preferring the
lowercase key and falling back to the capitalised one, and unescaping the
backslashes in destinationDir.

Tested against the real 44-entry catalogue, all three outcomes:
  OptiPlex Micro 7020, pack present  -> OK, win11_optiplexd13mlk7020_a09.zip
  same model, pack removed           -> FAIL, names the missing path
  Surface Laptop 7                   -> FAIL, no pack matches

Worth noting the check was failing SAFE - a WARN that reads like a tooling
glitch rather than a missing driver pack. It would have stayed invisible until a
bay imaged with no NIC.
2026-08-06 17:24:04 -04:00
cproudlock
8907a86c42 Collector: decode StartupApproved 0x04 and 0x05
The post-fix capture from 579C144 reported three entries as unknown(0x04) -
SecurityHealth, RtkAudUService, WavesSvc. The decoder only knew 02/06 enabled and
03/07 disabled. 04 is also enabled and 05 also disabled; without them the report
says 'unknown' for entries that are perfectly ordinary.
2026-08-06 17:16:57 -04:00
cproudlock
a7cb7164d9 Kiosks stop installing shopfloor apps, and empty config stops meaning everything
PREINSTALL SCOPE

Sixteen entries carried PCTypes ['*'], so a Display kiosk installed Adobe
Acrobat, OpenText HostExplorer, WJF Defect Tracker, the shopfloor serial drivers
and twelve legacy VC++ redistributables. Those are now scoped to the nine
machine-tool types.

A Display now installs 3 of 21 entries:
  PowerShell 7.5.4                    SFLD-ApplyDSCConfig runs pwsh.exe 7
  VC++ Redistributable 2015-2022 x64  modern x64 runtime Edge/WebView2/PS7 lean on
  Display Kiosk App                   already Display-scoped

Kept as wildcards deliberately - removing the x64 runtime risks breaking the
kiosk itself, and PS7 is a hard dependency of the DSC task. Everything else a
kiosk has no use for. Edited as targeted text replacement, 16 line pairs, so the
file's formatting and its long _comment blocks are untouched.

THE LAST OF THE EMPTY-MEANS-DEFAULT BUGS

Configure-PC.ps1 and 07-TaskbarLayout.ps1 had the same test as the two fixed
earlier: $null -ne $cfg -AND .Count -gt 0, so an explicitly empty list fell
through to a hardcoded shopfloor default. Configure-PC is the one that actually
bit - it runs in the finalization phase and put these in the all-users Startup
folder on 579C144, timestamped mid-image:

    Defect Tracker.lnk   16:46:03
    Plant Apps.lnk       16:46:03

Plant Apps launching msedge --new-window on a kiosk. eDNC was in the same
hardcoded list and only escaped because its Test-Path guard found no DncMain.exe.

08-EdgeDefaultBrowser.ps1 had it too, with a Plant Apps + WJ Shopfloor + Dashboard
tab fallback. Harmless today because the Display profiles configure a real tab,
but one edit away from biting.

Get-ProfileValue returns $null only when a key is absent from BOTH the profile
and site-config, so $null is the only honest "not configured" signal and an
empty array means what it says.

VERIFIED against the post-fix capture: the S: mapper Run key and the machine
number prompt task are both gone, and 'ShopDB Kiosk Bootstrap' registered
correctly with boot and time triggers.

Also: the collector's StartupApproved decoder now handles 0x04/0x05, which that
same capture surfaced as unknown.

Still baked into the WIM and unaffected by any of this: WJ Shopfloor.lnk, dated
April, sitting in the all-users Startup folder.
2026-08-06 16:58:53 -04:00
cproudlock
28efde7d76 Stop Display bays getting machine-tool shortcuts, prompts and the S: mapper
Three fixes from the 579C144 diagnostics, all the same shape: a Display kiosk
being treated as a machine-tool bay.

desktopApps: configuring "none" produced "everything"

  "Display-Dashboard": { "desktopApps": [] }

06-OrganizeDesktop.ps1 tested $null -ne $cfgApps -AND .Count -gt 0, so an
explicitly empty list fell through to the hardcoded fallback and the kiosk was
given UDC, eDNC, NTLARS, WJ Shopfloor and Defect_Tracker shortcuts.
Get-ProfileValue returns $null only when the key is ABSENT from both the profile
and site-config, so $null is the real "not configured" signal and an empty array
means what it says. startupItems carried the same test - harmless today because
its else branch has no fallback list, but commented so the two cannot drift.

Machine-number prompt on a machine with no machine number

Register-CheckMachineNumberTask.ps1 armed 'Prompt Machine Number' (AtLogOn,
BUILTIN\Users) whenever the number was the 9999 placeholder - which is always
true on a Display, because startnet only collects a real number for machine-tool
types. A logon dialog on a kiosk with no keyboard. Now skips PC types that have
no machine number by design, and clears any stale task.

S: mapper on a share-less PC

Displays are Entra-joined with local accounts and no SFLD credentials, so mapping
S: can only fail, once per logon, forever. Run-ShopfloorSetup already gated the
CALL on $noEnforceTypes, but the bay registered it anyway at 15:07:55 with no
"Skipping" line in the log - so something in the finalization phase reaches the
registrar past that gate. Rather than chase the caller, the registrar now gates
itself and removes a stale Run entry. The call-site gate stays; this makes the
outcome correct regardless of who invokes it.

That bypass is worth understanding separately - the same pattern would defeat any
call-site gate in the finalization phase.

All three parse clean and are deployed byte-identical to the share.
2026-08-06 16:23:18 -04:00
cproudlock
d5cf255443 Kiosk bootstrap: look for the installer where staging actually puts it
The bootstrap looked for C:\Enrollment\kiosk\Install-ShopdbKiosk.ps1, but
startnet copies the type-specific tree to
C:\Enrollment\shopfloor-setup\gea-shopfloor-display\ - so the payload lands
beside the bootstrap, not under C:\Enrollment.

As written it would have logged "Installer not staged" on every 15-minute cycle
and never installed anything, with no error anywhere. Exactly the silent-failure
shape this whole day has been about, and it would have looked like the API being
unreachable.

Resolves via a -KioskRoot parameter defaulting to kiosk\ next to the script, so
the bootstrap and its payload travel together regardless of where the tree is
staged. display-type.txt still comes from EnrollmentRoot, which is correct - that
one really is at C:\Enrollment.
2026-08-06 16:12:41 -04:00
cproudlock
7ed30f9b85 Display: install the ShopDB enforce client once AESFMA is reachable
A PXE-imaged display ends up with no GE-Enforce client at all. Confirmed on
579C144, 2026-08-06:

    is the client installed?            NOT FOUND
    scheduled tasks that would run it?  NONE

Not a broken configuration - nothing had ever tried. Install-ShopdbKiosk.ps1
downloads itself from {BaseUrl}/installers/kiosk over HTTPS, and ShopDB is only
reachable after the bay joins the AESFMA wifi SSID, so it cannot run during
imaging. Nothing was arranged to run it afterwards.

09-Setup-Display.ps1 now registers 'ShopDB Kiosk Bootstrap' as a SYSTEM task at
boot and every 15 minutes. Install-ShopdbKiosk-WhenOnline.ps1 does nothing until
ShopDB answers, then runs the vendor installer once, verifies BaseUrl and the
enforce task exist, deletes the staged key and unregisters itself. A bay cabled
up days later still self-configures.

It deliberately does not reimplement the installer - it waits, invokes, verifies
and cleans up, so the vendor script can be replaced wholesale without touching
this.

The key file is LABELLED (collector=, fetch=) rather than positional. The two
tokens are not interchangeable and a mix-up is silent: a fetch token in the
collector slot leaves asset reporting broken while everything looks configured.
A bare unlabelled line is ignored rather than guessed at. Missing keys are not
fatal - the fetch token is unnecessary on an IP-allowlisted subnet, and the
installer skips the asset-report task rather than failing.

Staged on the share: the bootstrap, the vendor installer under kiosk\, and the
updated display setup. The key itself is NOT staged yet - see the commit
discussion; it needs to be labelled with which scope it carries first.
2026-08-06 15:58:15 -04:00
cproudlock
c4aeaaaa17 Collector: exclude the 8 GB package, fix base-URL detection, note the AESFMA dependency
Three defects found by the first real run, on 579C144.

16 GB collection, zip failed. run-enrollment harvests
C:\ProgramData\Microsoft\Provisioning into C:\Logs\PPKG, so the 8 GB
provisioning package existed TWICE under the trees being copied. Compress-Archive
cannot exceed 2 GB and died with "stream was too long". Copies now exclude
*.ppkg, *.wim and *.iso and cap individual files at 100 MB.

Base-URL detection matched the wrong value. The name pattern accepted anything
containing "base", so it picked up baseVersion=2.0.2 from
HKLM:\SOFTWARE\GE\SFLD\Credentials and then probed "2.0.2/api/docs" - making
both UNREACHABLE lines meaningless. Now matches the name exactly and requires the
value to start with http.

Unreachable was reported as a bare failure. ShopDB is only reachable once the bay
has joined the AESFMA wifi SSID, so on the imaging LAN or plain wired an
unreachable result is EXPECTED. It now says so, rather than looking like a fault.

The run still answered the question it was written for: no enforce client is
installed and nothing is scheduled to call the API, so this bay was never going
to reach GE-Enforce regardless of network.
2026-08-06 15:38:42 -04:00
cproudlock
54176e591f Collector: record whether each startup item is enabled or disabled
Disabling a startup item through Task Manager or Settings does not remove the Run
key or the Startup shortcut - Windows writes a flag into StartupApproved instead.
So the collector would have listed those entries as present with no indication
they had been switched off, and a bay where someone had already tidied up would
look identical to one where nothing autostarts.

Reads all five StartupApproved keys, including Run32 for the WOW64 view, and
decodes the first byte: 02/06 enabled, 03/07 disabled.

This matters for the current investigation because turning an item off is a
per-machine workaround - the next imaged bay installs and starts it again. The
fix is not installing it, which needs the app-to-autostart mapping this section
preserves.
2026-08-06 15:28:47 -04:00
cproudlock
d185e2b810 Collect everything needed to debug an imaged bay, in one pass
Every imaging investigation so far has meant walking to a bay and hand-copying
files, and each time discovering another thing we wished we had grabbed at the
same moment. This takes the lot. Read-only; changes nothing.

Collects: computer name (active AND pending, which is how you tell whether the
package's H<serial> -> F<serial> rename has landed or is still queued for the
next reboot), dsregcmd enrollment state, the package self-checks, installed
applications, everything that starts by itself, kiosk shortcut targets, Edge
policy, GE-Enforce config, drivers, disk, network, provisioning sessions, and the
whole of C:\Logs, Panther and the provisioning data plus the diagnostics evtx.

Two things it does deliberately:

Autostart is captured across all four surfaces - Run/RunOnce in BOTH registry
views, all Startup folders, non-Microsoft scheduled tasks, and auto-start
services outside C:\Windows. That combination is what identifies which installer
planted a given autostart entry. A 32-bit installer's Run key lands under
Wow6432Node where 64-bit tooling never looks, which is exactly how the old
Dashboard/Lobby autostart survived an earlier purge.

It TESTS the ShopDB GE-Enforce API rather than just reporting config. Config on
disk proves nothing - a client can be present, configured, and never once
succeed. It reports whether a client exists at all, whether anything is scheduled
to run it, the configured base URL, and then actually probes the endpoint. A 401
is a good result: it proves DNS, routing and TLS work and the service answered.
Only a timeout or DNS failure means unreachable. No token is sent.

Deliberately avoids Win32_Product - querying it triggers an MSI reconfigure of
every installed product, which is slow and can change the machine.

Run it BEFORE lockdown. What it captures is known-CURRENT, not known-good: a bay
straight off the line carries applications that should not be there, because
preinstall.json entries without a PCTypes filter install everywhere. The point is
to have an exact record of what imaging really produces so the unnecessary items
can be identified and filtered. After lockdown you cannot tell whether something
is absent because lockdown removed it or because imaging never installed it.

Staged on the enrollment share alongside the other shopfloor-setup scripts.
2026-08-06 15:24:25 -04:00
cproudlock
68f2ce29e4 Proposal: full reference map, and correct the LTSC autostart assumption
REFERENCES (section 13, was six lines, now a map)
Where every referenced thing lives, so the document is usable by someone without
today's context: this repo, the PXE server share, shopdb-flask, the on-bay log
locations, and the evidence bundles each claim came from. Plus re-derivation
recipes - pulling startnet out of boot.wim, extracting customizations.xml from an
8 GB .ppkg without unpacking it, reading the provisioning evtx, and the two
validation commands - and the 2026-08-06 commit list.

LTSC AUTOSTART - CORRECTED
Section 7.1 previously argued PESetup might already auto-start: AutoStartCountDown
is 31, disableauto.json is absent, and the operator on 579C144 clicked Next at 11
seconds, so nobody had waited long enough to find out.

Per the team's operational experience that is wrong for our media. LTSC builds
cannot autostart - the SELECT OS confirmation is mandatory regardless of the
countdown, and every image we produce is Windows 11 LTSC 24H2.

So the countdown is not a route to zero-touch. That promotes re-implementing the
imaging step from a contingency to the actual decision, and the risk table and
open questions now frame it as "re-implement, or accept one click per build" -
zero-touch being the only thing that requires it. The hands-off test is kept, but
only to confirm and record the behaviour first-hand.
2026-08-06 15:05:22 -04:00
cproudlock
f442ff2f65 Propose zero-touch, ShopDB-driven imaging
Design proposal, nothing built. Two ideas in one architecture: the image becomes
thin (partition, apply, join Entra, install the enforce client - everything else
is data pulled from the API and enforced by GE-Enforce), and imaging becomes a
job rather than a menu (open a machine in ShopDB, click Create this PC, pick the
zeroed box waiting in WinPE, and it images start to finish reporting progress
onto that machine's record).

Every claim about current behaviour is sourced from 2026-08-06 evidence -
decompiled binaries, live logs off bay 579C144, and the live share - and where
something is inferred it says so.

Covers: why the current early-decision chain fails silently (five worked examples
from one day); what already exists to build on; per-machine configuration records
replacing 147 .reg files and two CSVs, referencing payloads rather than embedding
them; the dual-NIC design with the firewall work that gates it; whether PESetup
needs replacing at all; the BPRT token shelf-life constraint on pre-imaged stock;
an ownership table; six delivery phases each useful alone; risks; and seven open
questions.

Two findings in here are worth acting on before any of it:

  disableauto.json is ABSENT from the media and AutoStartCountDown is 31, while
  the operator on 579C144 clicked Next at 11 seconds. PESetup may already
  auto-start and nobody has waited long enough to find out. One bay, hands off
  the keyboard, count to 40.

  dnsmasq is already safely bound (listen-address + bind-dynamic) so DHCP will
  not leak onto a GE-side NIC - but Samba binds to all interfaces and every ufw
  rule is ALLOW IN Anywhere, including SSH on a pxe/pxe account. That work gates
  dual-homing.
2026-08-06 15:02:08 -04:00
cproudlock
a0aceb4680 startnet: define the server address and share credentials once
The PXE server address appeared 14 times and the share credentials 13, so moving
the server or rotating the account meant editing every net use in the file and
rebuilding boot.wim - with no way to tell from a diff whether one had been
missed. Now set once at the top as PXESERVER / SHAREUSER / SHAREPASS.

Exactly one literal address remains, the definition on line 6. Prose in REM
comments is left alone so the paths stay readable.

This also reaches the post-boot self-heal for free. startnet already writes
C:\Enrollment\fetch-source.txt, and both Fetch-StagingPayload.ps1 and
Verify-And-Heal-Staging.ps1 read it (line1=UNC, line2=user, line3=pass) with
their own hardcoded values only as a fallback. Confirmed on 579C144:

  [13:30:35] [INFO] fetch-source.txt: UNC=\172.16.9.1\enrollment user=pxe-upload
  [13:30:36] [INFO] Mounting \172.16.9.1\enrollment as Z: (attempt 1/5)...

so the seeded file wins and the heal follows PXESERVER automatically. My earlier
claim that the heal scripts were hardcoded was wrong - they were already
config-driven and startnet was already seeding them. The hardcoding was all in
startnet itself.

Verified: set on line 13, first use on line 20; parens balance; every goto
resolves; 1005 CRLF lines with no bare LF; the deployed copy contains exactly one
literal address. boot.wim md5 7a9b832b.
2026-08-06 14:41:40 -04:00
cproudlock
bb08392b84 download-drivers: reuse an existing directory that differs only in case
Dell and GE manifests spell the same folder inconsistently - OptiPlex vs
Optiplex. Windows does not care, but the Samba share sits on a case-sensitive
filesystem, so a blind mkdir -p created a SECOND tree and the drivers split
between them.

That is exactly how the OptiPlex Micro 7020 pack went missing: the manifest asked
for OptiPlex/D13MLK while the 3.2 GB zip sat in Optiplex/D13MLK. PESetup found no
pack, logged a warning rather than an error, and the bay imaged with no network
drivers - so DNS failed at first boot and bulk enrollment could not reach the
CDN. Symptoms three steps from the cause.

mkdir_ci walks the path one component at a time and reuses whatever is already
there whatever its case, creating only genuinely new components. Callers must use
the RETURNED path, since it may differ in case from the requested one. Falls back
to plain mkdir -p rather than skipping a download if the resolve fails.

Both spellings still exist in the live catalogues; scripts/lint-driver-catalogue.py
reports a case mismatch as an error so the pair cannot silently drift again.
2026-08-06 14:38:54 -04:00
cproudlock
9d51c0b987 Ask tenant + PC purpose at the PXE menu; add the missing 3D kiosk display option
PC PURPOSE / TENANT FOR STANDARD AND ENGINEER

The capability was already there - the media carries all six MCL packages
(PERS/SH/LOAN x GCCH/RoW) and Start-BulkEnrollOrchestrator.ps1 picks one from
whatever Select-PCConfig.ps1 returns. The problem was WHEN it asks: a GUI at
first logon, so an otherwise unattended build stops at a dialog until someone
walks over.

Now startnet asks after Standard/Engineer is chosen, while the tech who knows
what the machine is for is standing at it, and writes {Tenant}_{Purpose} to
C:\Enrollment\pc-config.txt. A shim at the path the orchestrator already calls
returns that value and falls through to the vendor dialog when it is absent or
malformed - so "ask at first logon" stays available and bays imaged before this
are unaffected. The vendor script is preserved as Select-PCConfig-vendor.ps1;
replace THAT when the vendor ships a new one.

The tenant is not cosmetic. Insert-BPRTToPPKG.ps1 matches the bulk token on
Tenant AND Purpose, so this selects which token gets injected.

ONLY THE CHOICE IS PRE-SEEDED, deliberately. Injection stays at first logon
because it fetches an encrypted token table from mcl.dwcdn.geaerospace.com,
unreachable from the isolated imaging LAN - which is also why routing MCL
packages through run-enrollment.ps1 would not work: provtool would get a package
whose token is still the Exp_XXXXXXXX placeholder and join nothing.

Standard/engineer previously skipped staging entirely (no ppkg, no PCTYPE), so
the staging gate now also admits PCCONFIG.

3D PARTS KIOSK

The display submenu offered Dashboard and Lobby only. The GE-Enforce display
scope has always had a third entry - 3DPrintRoom -> /shopdb/parts-kiosk - so a
parts kiosk could only be set by editing display-type.txt by hand after imaging.
Added as option 3. The value is a KEY into that map, so the spelling matches
exactly.

Verified: startnet parens balance, every goto resolves, 994 CRLF lines with no
bare LF; shim parses clean. Deployed - boot.wim 4d16c946, shim staged with the
vendor dialog preserved.
2026-08-06 14:37:20 -04:00
cproudlock
68df59e117 Record who owns what, and report repo-vs-share drift
OWNERSHIP.md
Every expensive bug in this pipeline has had one shape: two systems setting the
same thing, last writer winning silently. Four happened on 2026-08-06 alone -
computer name (package vs run-enrollment), drive letters (PESetup vs a volume
finder), enrollment (package vs an at-logon -ManualFallback task that syspreped
finished machines), kiosk URLs (GE-Enforce vs site-config). Each is written down
with the evidence so the next person deletes a writer instead of adding one.

share-drift.py
The share is production and the repo is meant to describe it, but drift runs both
ways: live hand-edits nobody committed, and repo fixes never deployed. The
unattend outage lived only on the share while the repo copy was fine, and nothing
compared them.

Each mapped pair is classified. git-owned means the repo wins and the pair must
match - those fail the run. unreconciled means the two have genuinely diverged
and nobody has decided; reported, not failed. The unattends are unreconciled on
purpose: live is ~17 KB against ~12 KB in the repo, so a blind push would regress
production. Reads over SSH via base64 so BOM and CRLF survive the hop.

First run: 8 git-owned pairs all match, 4 known-unreconciled.

Run-ShopfloorSetup.ps1
Corrects a comment that was actively misleading. It claimed shopfloor PCs are
"vanilla by design" and that the orchestrator runs -ManualFallback to skip BPRT
injection and the package entirely. Shopfloor bays DO enrol - the SFLD package
joins Entra with its BPRT token and a human assigns the device category in
Intune. -ManualFallback runs sysprep /oobe /reboot, which is why wiring it to an
at-logon task destroyed the deployment chain.

The absent Entra wait is still correct, for a different reason: at that point the
bay is on the isolated PXE LAN with no route to Entra (579C144 held 172.16.9.81
and 172.24.19.142, neither in the production ranges). sync_intune retries until
the tech re-cables. "Entra ID Joined: false" right after imaging is normal.
2026-08-06 14:28:16 -04:00
cproudlock
d2200e8522 Preflight: check what PESetup fails on, before it fails on it
TWO SCRIPTS, DIFFERENT AUDIENCES

playbook/scripts/preflight.ps1 runs at the bay, called by startnet once the
media is mapped. It checks the four things that come straight out of PESetup's
own behaviour:

  secure boot   GatherDataSelection fails outright when SecurebootEnabled != 1
  disk >= 120GB MinRequiredSpaceWithoutCompression is 128849018880
  driver match  reimplements GetDriverByModel - family filter, untrimmed
                comma-separated substring tokens, first match wins - and checks
                the pack is actually on the media
  media age     media expires 30 days after build; Media.tag's timestamp is the
                local proxy

The driver check is the one that earns it. A miss is only a WARNING to PESetup,
so the bay images with no NIC, DNS fails at first boot, and enrollment cannot
reach the CDN - a symptom three steps removed from the cause. Advisory by
design: it reports and pauses on a blocker, the tech decides. Lives on the
enrollment share so it can be fixed without rebuilding boot.wim.

scripts/preflight.py runs on the server before a build day and aggregates
everything already built - driver catalogue lint, unattend lint, per-PCTYPE
media view verify - plus a new advisory firmware-coverage check that lists
catalogued models with no BIOS models.txt entry. That last one is how the
OptiPlex 7020 family sat uncovered: 127 catalogued models, 58 covered today.

First run: driver catalogues clean, all three unattends clean, firmware coverage
advisory only. PREFLIGHT PASSED.

Verified: both scripts parse clean (PowerShell parser / python), startnet parens
balance, every goto resolves, 915 CRLF lines with no bare LF. Deployed -
boot.wim md5 99fd3132, preflight.ps1 on the share.
2026-08-06 14:22:01 -04:00
cproudlock
8c21282024 Verify staging in WinPE, harvest imaging logs, make enrollment run once
Three changes aimed at the same failure mode: a bay that images green and is
silently unusable, diagnosed only by walking over and copying files off by hand.

VERIFY STAGING (startnet, at :pctype_done)
Checks pc-type.txt, Run-ShopfloorSetup.ps1, shopfloor-setup/common and
shopfloor-setup/<PCTYPE> exist on the applied volume before anything depends on
them, retries the small trees once, and prints a loud banner if the retry does
not fix it. The enrollment package is checked but never blindly re-pulled - it is
8 GB, so a miss is reported instead.
Done here because a tech is still at the machine: a short copy found in WinPE
costs 30 seconds, the same copy found at first logon costs 20 minutes, and found
never costs a rebuild. Four Display bays sat green at stage 2 for weeks.

HARVEST LOGS (startnet, after PESetup exits)
Collects X:\*.log, the generated X:\Unattend.xml, PESetup's own log from the
target's Panther directory and winpe-staging.log into
\<server>\enrollment\imaging-logs\<serial>\, plus a build-context.txt naming
PCTYPE, PPKG, machine number and media. All of it was being discarded at reboot.
Runs after PESetup exits so the logs are final, which means re-mapping Y: since
cleanup already dropped it. Best-effort throughout - a bay must never fail to
reboot because a log copy failed.

W: WAIT CAP 20 -> 45 MINUTES
270 polls instead of 120. Fine on NVMe either way, but a WIM apply can exceed 20
minutes on slow media, and the failure mode is the os_not_found banner plus
nothing staged. The loop still exits the moment the SYSTEM hive appears.

RUN-ENROLLMENT RUNS ONCE
Marker at C:\Enrollment\.ppkg-applied, written on exit 0 and also on 0x800700B7
ERROR_ALREADY_EXISTS. Observed running twice on 579C144; the second pass
re-applied a pending rename over the package's own and otherwise did nothing.

Verified: startnet parens balance, every goto resolves, 899 CRLF lines with no
bare LF; run-enrollment parses clean under the PowerShell parser. Deployed -
boot.wim md5 159c2a4d, live run-enrollment dce9d50a.
2026-08-06 14:18:25 -04:00
cproudlock
36be60e9ae run-enrollment: stop renaming the computer, the PPKG owns it
The package declares <DNSComputerName>F%SERIAL%</DNSComputerName>, so bays are
meant to come up as F<serial>. This script hardcoded a rename to E<serial>,
which is a second pending rename racing the package's own.

Observed on 579C144, 2026-08-06:

  13:31:55  run-enrollment  Rename-Computer -> E579C144      (pending)
  13:32:17  ppkg            Reboot/DeviceName -> F%SERIAL%   (pending)
            Provisioning-Diagnostics: "Reboot successfully scheduled.
            Device name set successfully."
  13:36:45  run-enrollment runs AGAIN -> E579C144            (overwrites F)
  ~13:37    reboot; E579C144 wins

Last writer wins at reboot, so the hardcoded prefix silently beat the package.
The bay came up E579C144 with the ppkg reporting no errors at all - 535
Provisioning-Diagnostics records, every one of them Level 4.

Nothing in this repo ever produced an F name; grep for a prefix rule or a
namePrefix setting finds nothing. The convention only works if naming is left to
the package, so the rename is removed rather than corrected to F. That also
makes it site-agnostic: a different package can name bays differently without a
script edit.

The double execution is a separate defect and is left alone for now. With the
rename gone a second run is harmless: provtool returns 0x800700B7
ERROR_ALREADY_EXISTS and changes nothing.
2026-08-06 14:06:58 -04:00
cproudlock
b96ff7e970 Retire the shopfloor-dashboard route; point Lobby at its own page
The dashboard moved to /shopdb/shopfloor. Every reference here still pointed at
/shopdb/shopfloor-dashboard/, which no longer resolves.

Display-Lobby was also pointing at the DASHBOARD url, not the lobby one, so lobby
screens showed the shop floor dashboard. The routes now follow the authoritative
map in shopdb-flask plugins/geenforce/seed_display_scope.py:

    Dashboard    -> /shopdb/shopfloor
    Lobby        -> /shopdb/tv
    3DPrintRoom  -> /shopdb/parts-kiosk   (no imaging-time menu option yet)

Changed: urls.shopfloorDashboard, Display-Dashboard.edgeHomepage,
Display-Lobby.edgeHomepage, and the Shopfloor Dashboard fallback in
08-EdgeDefaultBrowser.ps1.

Note these values are now a backstop rather than the primary path. Displays are a
single image whose variant comes from C:\Enrollment\display-type.txt, and the
GE-Enforce display scope delivered over the shopdb API writes the kiosk Startup
shortcut itself - preferring the server-side role from Settings > Dashboard
Defaults and falling back to display-type.txt. That dispatcher also SWEEPS
shortcuts matching shopfloor-dashboard, so a stale value here would have been
deleted on the next enforce cycle rather than honoured.

Edited by line position, not string match: the two edgeHomepage values were
textually identical and belong to different display blocks. Formatting preserved
- a json.dumps round-trip reflowed 632 lines and was reverted.
2026-08-06 12:30:35 -04:00
cproudlock
d04865da0f docs: record the PESetup version actually in production; match LogonCount to the live unattend
PESETUP-INTERNALS.md was written from a decompiled 4.0.0.17. The media in
production reports 4.0.0.20 in its own log. Rather than restate the document as
4.0.0.20, which would claim a re-derivation that has not happened, it now names
both: line-level claims are 4.0.0.17, and the behaviour re-observed on bay
579C144 on 2026-08-06 is listed so a reader knows which parts are confirmed
current - media drive Z:, W: created by PrepareDisk and used for every copy
destination, the fallback Deploy\FlatUnattendW10.xml being the unattend that
loads, and driver selection by model.

FlatUnattendW10-shopfloor.xml carried LogonCount 7 while the live shopfloor
unattend has 12, and Run-ShopfloorSetup.ps1's comment about topping up the
autologon budget already said 12. The live value is the real one, so the repo
follows it.

Both files still lint clean under scripts/lint-unattend.py.
2026-08-06 12:25:30 -04:00
cproudlock
2b6189f739 startnet: stage against PESetup's own W:, and stop reporting failed copies as successes
Brings the repo back in line with what is actually running in boot.wim, validated
end to end on bay 579C144 today.

WHAT THIS FILE NOW DOES DIFFERENTLY

Applied-volume detection. The volume finder and the diskpart letter reassignment
are gone. PESetup hardcodes W: in nine places and creates it during its own disk
preparation, so there is nothing to search for, and searching actively broke
staging: a scan run before PESetup finished disking returned the PREVIOUS
install, startnet relabelled that partition W:, and PESetup's own DISKPART then
erased it mid-copy. Timestamps from that failure are in the comment so nobody
rebuilds the finder from the same premise. The wait is back on
W:\Windows\System32\config\system, which only exists once the WIM apply has
written it.

Copy reporting. Eight sites echoed "Copied ..." unconditionally, so a run where
every robocopy exited 16 and moved zero bytes still printed six success lines.
They now branch on errorlevel 8 and say FAILED, including in the staging log.
%ERRORLEVEL% is no longer printed inside parenthesised blocks - it expands at
parse time there and showed a stale value. The WaxTrace test moved above its
mkdir, which sets its own errorlevel.

Per-PCTYPE media. Maps Z: to _media\<PCTYPE> and probes
Z:\Deploy\Control\HardwareDriver.json before trusting it, falling back to the
shared media with a warning.

findstr -> find. findstr.exe is not in this WinPE image. It failed with errorlevel
9009, which "if errorlevel 1" read as true, so an SFLD package took the non-SFLD
branch and never got its BPRT-tagged name.

VERIFIED ON A REAL BAY, from C:\Enrollment\winpe-staging.log:

    PPKG=GCCH_Prod_SFLD_NoOffice_US_Exp_20260831_v4.16.ppkg
    Copied PPKG GCCH_Prod_SFLD_v4.16.ppkg as GCCH_Prod_SFLD_NoOffice_US_Exp_...
    Shopfloor 44 files / common 17 / _ntlars 147 / display 1 / preinstall 85
    FAILED: 0 across every copy
2026-08-06 12:25:18 -04:00
cproudlock
385acfefa7 Lint unattend answer files before they reach a bay
Well-formed XML is not enough. Windows Setup validates against a schema and one
bad value invalidates the WHOLE answer file for its pass, so the machine stops at
a dialog with nothing configured and the only clue is an XPath buried in
C:\Windows\Panther\setupact.log.

On 2026-08-06 every shopfloor, standard and engineer build was failing exactly
that way:

    /settings/RunSynchronous/RunSynchronousCommand/[Order="16"]/Path
    Description = Value is invalid.   hrResult = 0x80220005   pass = specialize

An inlined "powershell.exe -Command ..." had grown to 676 characters in a field
capped at 259, and had been broken since the previous evening. Every check here
is mechanical and would have caught it before a bay was booted:

  path-too-long         RunSynchronousCommand/Path     > 259
  cmdline-too-long      SynchronousCommand/CommandLine > 1024
  description-too-long  Description                    > 256
  duplicate-element     a once-only element appearing twice
  not-well-formed       XML does not parse
  bom                   UTF-8 BOM (the live files have none, and python's
                        utf-8-sig silently ADDS one when writing them back)
  unknown-token         %token% PESetup will not substitute; %WINDIR% and the
                        other shell variables are excluded or it cries wolf

Reads over SSH via base64 so a BOM or CRLF survives the hop unchanged. Exits
non-zero on any ERROR so it can gate a deploy.

Immediately found a second live defect the manual review had missed: a 329-char
Description on gea-engineer Order 11, which would have failed the oobeSystem pass
once specialize started passing. All three live image types and both repo copies
now lint clean.
2026-08-06 12:24:59 -04:00
cproudlock
d5f7abc19f BIOS: cover the whole OptiPlex 7020 family, not just Tower Plus
An OptiPlex Micro 7020 reported "no update in catalog" at the WinPE menu. The
firmware was staged all along - OptiPlex_7020_1.22.1_SEMB.exe, 102 MB, sitting on
the share - but models.txt listed only "Tower Plus 7020". check-bios.cmd matches
with find /I, a substring test against the WMI model name, and "Tower Plus 7020"
is not a substring of "OptiPlex Micro 7020", so every Micro, SFF and Tower 7020
silently skipped its firmware update.

The 7010 family three lines above already shows the intended shape: one SEMB
package behind Micro, SFF Plus and Tower Plus entries. Dell packages 7020 the
same way, covering Micro, Micro Plus, SFF, SFF Plus, Tower and Tower Plus, so all
six now point at the one package and the stray Tower Plus line moves up to join
its family.

Verified by simulating the matcher over the manifest: all six variants resolve to
the 7020 package, no duplicate tokens, and 7010 still resolves to its own. Note
that "Small Form Factor 7020" spelled out does NOT match - the short form is
correct, since HardwareDriver.json's modelswminame field uses SFF 7020.

Deployed to the live share, where check-bios.cmd reads it directly off B:, so no
boot.wim rebuild is involved. Previous manifest kept at
/home/pxe/models-rollback-20260806.txt.
2026-08-06 11:10:13 -04:00
cproudlock
049dd7bde6 startnet: map the per-PCTYPE media view for shopfloor builds
Maps Z: to _media\<PCTYPE> instead of the shared gea-shopfloor media, so
PESetup's unfiltered CopyPackages stages only this type's payload rather than
every shopfloor payload on every bay.

The mapping is validated rather than assumed: it probes
Z:\Deploy\Control\HardwareDriver.json, which resolves only when the view exists
AND its symlinks are intact, so a dangling view cannot image silently off a
half-mapped drive. On any failure it falls back to the shared media with a
console warning - the bay still images, it just stages the union, which is
exactly today's behaviour. That fallback also covers the second WJ PXE box,
which has no _media views yet.

Z: is freed before the mapping. A leftover mapping makes net use fail with
"device already in use", and the probe would then pass against the OLD media -
a silent wrong-payload build, which is the failure this change exists to
prevent.

Views are built by scripts/build-pctype-media.py.
2026-08-06 11:03:12 -04:00
cproudlock
c7b17696d0 Build per-PCTYPE PESetup media views out of symlinks
CopyPackages copies the whole of <media>\Deploy\Applications to the target,
recursively, with no manifest and no filtering, so every bay built from the
shared gea-shopfloor media receives every shopfloor payload whatever its type.

The obvious fix - repoint a path inside the shared media as each bay picks its
type - races. Bays image concurrently and CopyPackages is fail-fast, so moving a
path while another bay is mid-copy either hands it the wrong payload or fails its
imaging outright, intermittently and unreproducibly.

Give each type its own media directory instead, built once and never mutated.
Everything in it is a symlink, so a type costs inodes rather than gigabytes and
the shared media stays untouched while bays read it. Samba already serves this
pattern: follow symlinks and wide links are on, and every image root reaches
Sources, Operating Systems, Out-of-box Drivers and Packages the same way.

Types come from the enrollment share's menu.json, the file the WinPE picker
already renders, so the media set cannot drift from the boot menu. Deliberately
NOT mirrored: the FlatUnattendW10.xml.pre-* backups and stray logs beside the
real unattend - the view is the clean set PESetup actually reads. Each type is
built into a scratch dir and swapped, so a bay mapping the path mid-refresh sees
the old tree or the new one, never a half-built one.

Applied on 172.16.9.1: 10 types, 190 links, all 60 critical paths resolve. An
SMB client sees ordinary directories and files - Sources, Control,
FlatUnattendW10.xml at 18078 bytes, HardwareDriver.json at 46113 - which is the
behaviour that actually matters, since PESetup reads this over SMB from WinPE.

Note for anyone extending this: passing the server-side script on stdin to
"echo pxe | sudo -S bash -s" is a silent no-op. Bash inherits the password pipe,
reads EOF, runs nothing and exits 0. It travels as a heredoc for that reason.
2026-08-06 10:56:18 -04:00
cproudlock
dfa026b4ee Lint the driver catalogue against PESetup's real matcher
A driver miss is only a warning: GetDriverByModel returns null, PESetup logs
"driver for [MODEL] not found" and images the machine anyway. The bay comes up
with no NIC and no WiFi, DNS fails at first boot, and bulk enrollment cannot
reach the CDN - symptoms far enough from the cause that the OptiPlex Micro 7020
pack sat missing and the Display MicroPC failures were blamed on a drive letter.

Reimplements the matcher from the decompiled source (docs/PESETUP-INTERNALS.md)
and reports what silently breaks it:

  virtual-platform  one such entry flips the tool into virtual-only mode and
                    hard-fails every physical machine
  empty-token       a trailing comma yields "", and Contains("") is true for
                    every model, so that entry swallows the catalogue
  token-whitespace  Split(',') does not trim, so " OptiPlex 3010" needs the
                    space present in the model string too
  case-mismatch     the share is case-sensitive; Optiplex vs OptiPlex splits the
                    tree and the pack is never found
  missing-zip       referenced pack absent
  family-mismatch   the family filter runs first, so a token whose line
                    contradicts the family field can never match
  shadowed          first match wins, so a later entry may be unreachable
  duplicate-token   osId is not part of the match, so a win10 pack can land on a
                    win11 build purely by ordering

--models resolves real WMI model strings through the same code, which is the
check that actually predicts a no-driver build. Exits non-zero on ERROR or
CRITICAL so it can gate a deploy.

Verified both ways: a synthetic catalogue carrying each defect reports all eight
and exits 1; the three live catalogues on 172.16.9.1 come back clean at 0. The
file listing needs find -L and the same anchoring as destinationDir - without
either, every zip check silently passes.
2026-08-06 10:46:28 -04:00
cproudlock
936902dc4d Document how PESetup.exe actually works
Written from the decompiled assembly rather than from observation. Three
long-standing beliefs about this tool are wrong, and each has cost real
debugging time:

W: is not a guess. PESetup hardcodes it in nine places - every copy
destination, the DISM offline sessions, bcdboot, reagentc - and creates it
during its own disk preparation. startnet.cmd's volume finder, diskpart
reassignment and W: wait loop are machinery built around a problem that does
not exist.

The copy steps do not filter. CopyPackages copies the whole of
Deploy\Applications recursively to W:\Deploy\Applications - no manifest, no
unattend parsing, no extension or size rules. Anything dropped there reaches the
target, which is the basis for staging our own payload without the enrollment
share. It is also fail-fast: one unreadable file fails the whole step.

A missing driver match is a WARNING, not an error. GetDriverByModel does a
substring test of comma-separated tokens with first-match-wins, the family
filter knows only Latitude, OptiPlex and Precision, and a single 'virtual
platform' entry in the catalogue fails every physical machine. A miss lets
imaging finish with no drivers, so no NIC, so DNS failures at first boot -
symptoms far from the cause, and easily misattributed to the drive letter.

Includes the bundle-extraction recipe so the next person can re-derive all of
this instead of trusting this document.
2026-08-06 08:04:48 -04:00
cproudlock
27e76467a8 manifest engine: resolve installer paths without the PS provider
Join-Path routes a drive-qualified path through the PowerShell provider, so
it returns null when that drive has gone away mid-cycle, and every consumer
then bound the null straight into Test-Path -LiteralPath and crashed the
entry with a message that names neither the entry nor the path.

Replace the six Join-Path $InstallerRoot sites with Join-InstallerPath, which
does plain string math, and have all six callers treat a null resolve as
"not found" and log it. Only the PS1 branch had a guard before, and it
covered a null Script value, not a null resolved path.

The helper deliberately avoids [IO.Path]::Combine: its separator and
rooted-path rules follow the host platform, so behaviour cannot be verified
off-Windows. It also rejects rooted values ("\x", "D:\x", "\\server\share"),
which Combine would have resolved outside the share root. Checked all 36
path values across the share manifests: none are rooted today.

Behaviour verified against the real manifest values (forward slashes,
backslashes, trailing-slash root, null, whitespace, rooted, UNC).
2026-08-03 17:23:00 -04:00
cproudlock
66c24b5d59 shopfloor: stop manifest entries hijacking GE-Enforce's drive letter
GE-Enforce mounts the SFLD share on W: and holds it for the whole enforce
cycle, passing W:\<pctype> as -InstallerRoot to Install-FromManifest. Two
scripts that run inside that cycle, as SYSTEM in the same drive namespace,
mapped W: for their own use and deleted it on exit:

  Restore-UDCData.ps1     manifest PS1 entry, mounts the UDC backup share
  Update-MachineNumber.ps1  "Apply Machine Number" task, same backup share

Once W: is gone, the next manifest entry's Join-Path resolves against a dead
drive qualifier. In Windows PowerShell 5.1 that emits nothing rather than
throwing, so the null lands in Test-Path -LiteralPath and the entry dies with
"Cannot bind argument to parameter 'LiteralPath' because it is null".

Observed on a collections bay: the controller-credential entry, which runs
immediately after UDC Data Restore, failed this way while the entry one line
earlier had resolved a W: path successfully one second before.

Restore-UDCData now uses R: and Update-MachineNumber uses N: for the UDC
backup share. Neither letter is referenced anywhere else in the tree; V: was
not an option because Restore-EDncReg and Update-MachineNumber's NTLARS
restore already use it.

The re-mount at GE-Enforce.ps1:239-244 does not cover this. It runs after the
manifest loop and blames idle SMB timeout, but the whole cycle took one
second, so timeout was never the cause.
2026-08-03 17:22:50 -04:00
cproudlock
1a175bc4fe FlatUnattend: revert broken prereqs block, keep OpenText shopfloor switch
The .NET3.5/VC++ prereqs block (11 RunSynchronousCommands with ~260-char doubled Paths + a long Description) made the answer file invalid for the specialize pass - 'Windows could not parse or process unattend answer file for pass [specialize]'. Rebuilt the template from the proven-working original with only the OpenText change (Order 19 -> Setup-OpenText.cmd, drop unattended.bat, short Description). Deployed to both live gea-standard + gea-engineer. The prereqs will be re-added correctly as a single short-Path wrapper cmd (install-prereqs.cmd) once imaging is confirmed.
2026-07-23 14:50:59 -04:00
cproudlock
01f524e8b3 std/eng OpenText: use shopfloor Setup-OpenText.ps1 (fans office+shopfloor profiles to all users)
Standard + Engineer imaged OpenText via the vendor Inno exe + unattended.bat, which left per-user profiles in SYSTEM's appdata (operators never saw keymaps/menus/macros) and only copied to Default+Shared. Switched their FlatUnattend to the shopfloor Setup-OpenText.cmd, which does the install via direct msiexec (OpenTextHostExplorer15x64.msi + ShopFloorx64.mst + SP1.msp) and fans the profile content (both office and shopfloor sets - Accessories/EB, Keymap, Menu, Profile) to ProgramData\Shared + Default user + every existing user. Kept both J2SE installs (Java). Dropped unattended.bat (its crude Default+Shared copy is superseded; NOTE its stale DNS SearchList reg add - old logon.ds.ge.com/rd.ds.ge.com domains - is no longer applied). Shopfloor opentext bundle staged into winpeapps/_shared/Applications/extra/opentext/.
2026-07-23 14:15:56 -04:00
cproudlock
3880151e71 webapp: shopfloor sub-menu tab + nav rename (the edits dropped by a failed git add)
The prior 'nest shopfloor menu as a tab' commit only captured the shopfloor_menu.html deletion - the git add also named the just-removed file, which errored the add and staged nothing else. This commits the actual integration that was already deployed to preview and prod: startnet_editor GET passes shopfloor_items/shopfloor_available, the Shopfloor Sub-Menu tab pane + sf-prefixed JS in startnet_editor.html, /shopfloor-menu redirects into the tab, and the sidebar 'startnet.cmd' -> 'Boot Menu' rename.
2026-07-23 13:58:08 -04:00
cproudlock
5f97e81dec webapp: nest shopfloor menu as a tab in the Boot Menu editor; rename nav to Boot Menu
The shopfloor sub-menu editor is now a 'Shopfloor Sub-Menu' tab inside the startnet editor (it IS a sub-menu of the boot menu), not a separate page - sf-prefixed IDs avoid clashing with the top-level Boot Menu tab. /shopfloor-menu GET + save now redirect back into that tab; standalone shopfloor_menu.html removed. Sidebar 'startnet.cmd' renamed to 'Boot Menu'.
2026-07-23 13:37:55 -04:00
cproudlock
626561a1fa shopfloor menu: data-driven from menu.json (picker + webapp editor)
Replace the hardcoded GEA Shopfloor PC-type sub-menu with a data-driven one:
- menu.json on the enrollment share lists the shopfloor items {key=PCTYPE, label, hint, enabled}; key must match a shopfloor-setup/gea-shopfloor-* handler dir.
- select-shopfloor-type.ps1 renders it in WinPE and writes the chosen PCTYPE (mirrors the CMM bay picker); startnet.cmd runs it and falls back to the baked-in menu if the share/picker is unavailable.
- Webapp /shopfloor-menu editor: reorder/rename/hide/add items; the PC-type is a dropdown of existing handler dirs (can't wire a choice to a non-existent type); writes menu.json. Nav link under Tools.
Kills the duplicated-knowledge problem (menu list was hardcoded in startnet AND the handler dirs AND site-config); add a PC-type = drop in the handler dir + it appears in the menu.
2026-07-23 13:27:08 -04:00
cproudlock
f1d9bdf478 startnet editor: harden _split_lines against doubled CRLF (\r\r\n)
A CRLF file re-CRLF'd (e.g. sed adding \r to already-CRLF lines) yields \r\r\n; the old _split_lines left a stray \r that split into a blank line between every line, so parse_boot_menu/settings/lint saw a garbled file and returned nothing. Now collapse any run of CR before a newline. Also re-deployed a clean-CRLF startnet.cmd into the live boot.wim (the earlier sed-based deploys had doubled the CR).
2026-07-23 11:42:51 -04:00
cproudlock
cc391529bd startnet editor v2: settings/menu forms, syntax-highlight+lint, backup/restore+diff
Rebuilt the /startnet editor (Fable/Opus 4-stage build) into tabs:
- Settings: server IP (+ menu timeout/default when a choice construct exists) as form fields; apply rewrites only the targeted tokens.
- Boot Menu: add/remove/reorder image entries; regenerates only the menu echo/dispatch + action blocks, refuses reorders that would desync the enrollment %choice% router.
- Raw: full-text editor (still source of truth) with line-number gutter, batch syntax highlighting, a lint panel (unmatched goto/label, CRLF), and diff-vs-current.
- History: timestamped snapshots on every save, per-row diff + restore.
New wim.py helpers (framework-free): parse/apply_settings, parse/apply_boot_menu, lint_startnet, save/list/read_snapshot, unified_diff; BACKUPS_DIR=/var/lib/pxe-webapp/startnet-backups.
Fable review fixed a CRITICAL pre-existing bug: update_startnet's newline=CRLF write retranslated posted CRLF into \r\r\n, corrupting boot.wim on every raw save; now normalizes to LF first. Also fixed a false CRLF lint warning (verbatim read) and menu payload validation. All JS inline (no CDN).
2026-07-23 11:37:24 -04:00
cproudlock
234295931e playbook: keep the driver store owned by pxe so download-drivers.py can push
download-drivers.py rsyncs driver packs into _shared/Out-of-box Drivers over ssh as the pxe user, but the store was created root:root so new model folders failed with Permission denied. Added a task to own the store pxe:pxe 0775 (root and the webapp still write it fine). Fixed live on 172.16.9.1 too (chown -R).
2026-07-23 11:13:11 -04:00
cproudlock
0ffdcc79ed enrollment: auto-activate uploaded ppkg via ppkg.conf (no boot.wim edit)
startnet.cmd now sources SOURCE_PPKG/PPKG_VER/PPKG_EXP from \\<pxe>\enrollment\ppkg.conf (baked-in values kept as fallback if the file is missing). The webapp writes ppkg.conf on upload for standard GCCH_..._v<ver>.ppkg files: version parsed from the filename, optional expiry field (blank keeps current), so a new ppkg goes live at next boot with no startnet/boot.wim edit. Enrollment page shows the active ppkg. ppkg.conf seeded on the share (v4.16) and the ppkg.conf-aware startnet.cmd deployed into boot.wim.
2026-07-23 11:02:43 -04:00
cproudlock
3b63697176 download-drivers: compare BIOS versions numerically, not as strings
parse_bios_catalog kept the 'latest' BIOS per model with a string compare, so e.g. '1.20.1' > '1.9.0' was False and it wrongly retained the older 1.9.0. Added _ver_tuple() and compare tuples of ints so the genuinely newest firmware wins.
2026-07-23 10:54:35 -04:00
cproudlock
0cb6b26c27 webapp import: checksum-aware sync (skip unchanged, update only changed)
Image import previously full-copied everything and rmtree'd existing target dirs on every run. Replaced the shutil copy/move/copytree/rmtree with deploy.sync_tree(), which shells out to rsync -a --checksum: files whose content already matches the target are skipped, only new or changed files are written, and existing target files not in the source are left untouched (merge, not mirror). move=True uses --remove-source-files (frees the SMB upload dir) and prunes emptied source dirs. Applies to the Deploy import, the _shared redirections, and the root-level items. Big re-imports now only rewrite what actually changed.
2026-07-23 10:45:58 -04:00
cproudlock
8fbae24b4d webapp: fix a11y contrast, heading hierarchy, prefix-safe modal actions
- Accessibility: dark ink (#00003d) on warning/success buttons, badges, and success alert - white text on #ff9500/#0ad64f failed WCAG AA (~2:1); dark-on-bright now passes.
- Heading hierarchy: sidebar brand h1 -> div.brand-title (with matching CSS selector), and standardized all page-header titles to a single <h1> per page (8 templates were h2).
- Prefix-safe modal JS: dashboard/backups/reports/enrollment delete+clone actions now build their form action from url_for(...'__K__'/'__F__').replace(...) instead of hardcoded paths, so they survive a URL-prefix mount.
2026-07-23 10:14:29 -04:00
cproudlock
18db077475 webapp: reskin to shopdb-flask design system
Adopt the shopdb-flask visual language across the PXE webapp (presentation only, Flask/Jinja logic unchanged):
- New static/pxe-theme.css: GE Aerospace palette (atmosphere-blue sidebar, sky-blue primary, avionics-green), Inter font stack, light/dark theming via data-theme + localStorage (key pxe-theme) with system-pref fallback, and card/button/table/form/badge/alert component styles layered over Bootstrap.
- base.html: shopdb-style sidebar (logo + title, nav sections, footer light/dark toggle) + theme boot script.
- All 13 content templates restyled to the new page-header + card/table/badge vocabulary; unattend_editor grouped per unattend-UX research.
- Fixed a pre-existing CRITICAL bug found during review: nested <form>s in image_config.html made Adopt submit the delete form and Delete-selected post every orphan filename regardless of checkboxes; split into standalone forms wired via the form= attribute.
Built by a Fable-orchestrated Opus workflow (17 agents). All 14 templates parse clean under Jinja2.
2026-07-23 10:05:09 -04:00
cproudlock
8df30017aa Playbook: reconcile live dnsmasq/samba/blancco hand-edits back into repo
The live PXE server carried working config the playbook would have clobbered on re-run. Committed back:
- dnsmasq: BIOS boot chain now the vendorclass PXEClient:Arch:00000 -> ipxe.pxe scheme (plus iPXE userclass HTTP chain), replacing the stale undionly.kpxe client-arch=0 scheme (undionly.kpxe was never staged in tftp-root). Bind by listen-address=172.16.9.1 + bind-dynamic instead of interface=+bind-interfaces.
- samba: add 'allow insecure wide links = yes' to the managed symlinks block (modern Samba silently disables wide links without it) and the [winpeapps_bios] read-only share.
- blancco iPXE menu: full BIOS/EFI arch split (direct-kernel vmlinuz-bde-linux / vmlinuz-ubuntu with ucode+kexec initrds) with a GRUB chain fallback, replacing the old single-line 'chain grubx64.efi'.
startnet.cmd was NOT a commit-back: repo is already ahead of the live boot.wim (deploy-pending).
2026-07-23 09:31:20 -04:00
cproudlock
b240cfea12 collections: add eDNC detection diagnostic scripts 2026-07-23 09:21:49 -04:00
cproudlock
e7be0f5391 gea-engineer: pre-stage .NET 3.5 + VC++ redists via FlatUnattend
Add RunSynchronous Orders 39-49: enable NetFx3 from staged sxs + install the VC++ 2005-2022 redistributables at specialize, before the first-logon engineering installer. Each command is if-exist guarded against the extra_engineering\prereqs payload, so gea-standard (which shares this template) skips them. Payloads staged on the share under gea-engineer Deploy\Applications\extra_engineering\prereqs.
2026-07-23 09:21:49 -04:00
cproudlock
0c4c2a245d Remove legacy ge-* image types (gea-only fleet)
Drop ge-standard/ge-engineer/ge-shopfloor-lockdown/ge-shopfloor-mce from image_types + standard_types, both startnet boot menus (choices + labels + net use), and the webapp IMAGE_TYPES/FRIENDLY_NAMES. Fleet is gea-* only; the empty ge-* winpeapps stub dirs were removed on the live share.
2026-07-23 09:21:49 -04:00
cproudlock
fdf63ab32f Shopfloor self-heal: bootstrap recovery scripts + Keyence/WaxTrace heal
Fixes Keyence (and CMM/WaxTrace) imaging where the target got only partial
data: the Y: SMB mount goes idle-dead during WIM apply, so WinPE staging dies
early (often just after site-config.json + ppkg). The first-logon self-heal
was meant to recover but its scripts were themselves staged past the death
point, so nothing ran.

- FlatUnattendW10-shopfloor.xml: new FirstLogonCommands Order-4 bootstrap that
  mounts the enrollment share fresh and pulls Fetch-StagingPayload /
  Verify-And-Heal-Staging / enrollment scripts into C:\Enrollment before the
  Order 5/6 heal runs. Recovery no longer depends on WinPE staging surviving.
  Keep CommandLine <=320 and Description <=252 chars: exceeding the unattend
  schema length limits makes the whole oobeSystem pass invalid (OOBE prompts,
  no autologon).

- Verify-And-Heal-Staging.ps1: add Keyence and WaxTrace heavy-payload heal
  branches (previously only CMM). Keyence re-pulls installers-post\keyence\
  <model> -> C:\KeyenceInstall\<model>; WaxTrace re-pulls the bundle (minus
  formtracepak) plus the bay-matched FORMTRACEPAK-V<ver>.iso.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:12:43 -04:00
cproudlock
64e1f7e088 Report-AssetToShopDB: log OS + boot time in the POST line
Adds os= and boot= to the POST log line so report-asset-*.log shows whether
the PC actually grabbed the OS version and LastBootUpTime (uptime source).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:31:12 -04:00
cproudlock
287ec86c12 Report-AssetToShopDB: fleet-wide reporting, more fields, dual NIC
- collect logged-in user (console user via Win32_ComputerSystem, bare
  username), pc-type (C:\Enrollment\pc-type.txt), make/model, OS version
  (caption + DisplayVersion + build), last boot time (for uptime)
- report BOTH corp and controller NICs (physical only), each with MAC,
  tagged IsMachineNetwork; was corp-only before
- machine-number sourcing adds C:\Enrollment\cmm\cmmid.txt (CMM bay id)
  and skips the 9999 placeholder everywhere
- intended to run from common\ (every pc-type), not collections-only;
  api.asp patch-style update keeps it from clobbering other types

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:41:36 -04:00
cproudlock
85e7d91b1a CMM: imaging manifest installs goCMM 2.12.3 (was 1.1)
Future PXE images now install goCMM 2.12.3 (NSIS, /S silent, -> C:\Program Files
\goCMM, runs no-admin). Detection switched to File on goCMM.exe (new install
path). The tsgwp00525 SFLD enforce manifest is deliberately left on goCMM 1.1 so
already-deployed bays are NOT auto-upgraded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:45:25 -04:00
cproudlock
2461804fc8 CMM: Backup-CMM also captures the whole C:\Program Files\WAI tree
Adds a wai_backup_<PC>_<ts>.zip (robocopy /E of C:\Program Files\WAI + the x86
path) alongside the goCMM + PC-DMIS backups, indexed in cmm-backup-index.json.
Captures machine/controller content beyond the per-version PC-DMIS grab. Can be
multi-GB if WAI holds the full PC-DMIS 2016 install.

NOTE: this only CAPTURES it. sync-cmm-backups.sh + Restore-CMM still handle only
gocmm/pcdmis zips - staging/restoring the WAI zip needs those updated too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:40:01 -04:00
cproudlock
48bc609eb5 CMM/DODA: fix DODA-bay profile resolution + goCMM 2.12 DataFolder + settings converter
- Get-PCProfile: subtype-strip fallback. DODA bays set pc-subtype.txt=doda, so
  the profile key became "gea-shopfloor-cmm-doda" which matched NO profile/alias
  -> Get-PCProfile returned null -> callers fell to hardcoded defaults (no
  PC-DMIS desktop icons; Defect Tracker / WJ Shopfloor / Plant Apps force-started).
  Now an unmatched compound key falls back to the bare pc-type (-> CMM). VM-tested:
  gea-shopfloor-cmm/doda + CMM/doda resolve to CMM (7 apps, PC-DMIS present);
  non-CMM unaffected.
- 09-Setup-CMM Step 2.5c: Active Setup seed for goCMM 2.12 DataFolder. goCMM 2.12
  stores its shared-data-dir in HKCU\Software\General Electric\goCMM\DataFolder
  (decompiled: RegistrySettings uses Registry.CurrentUser - per-user). Imaging as
  SupportUser wouldn't reach the ShopFloor operator's HKCU. Active Setup runs the
  StubPath once per user at first logon -> every user gets DataFolder=C:\geaofi\.
  VM-tested: StubPath writes the value with the trailing backslash intact.
- Convert-goCMMSettings.ps1: converts legacy goCMM 1.1 ApplicationSettings.xml ->
  goCMM 2.12 goCMMSettings.xml schema. VM-tested: output byte-identical to a real
  goCMM-2.12-produced CMM10 goCMMSettings.xml.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 08:59:08 -04:00
cproudlock
132c57ab39 CMM: create C:\GE PC-DMIS FRONT END at imaging + move regserver helper to Public desktop
- Ensure-PCDMISFrontEnd.ps1/.bat: standalone fix for already-imaged bays -
  create C:\GE PC-DMIS FRONT END + grant Users/Auth Users Modify. PCDToIGES.exe
  writes its error log there in its catch block; on a fresh bay the dir is absent
  (legacy front-end setup isn't part of imaging), so ANY PCDToIGES error becomes
  an unhandled DirectoryNotFoundException that crashes the export and masks the
  real cause (confirmed live on a CMM bay).
- 09-Setup-CMM Step 2.5b: create that dir + ACL at imaging for every CMM bay.
- 09-Setup-CMM Step 2.5: Register-PCDMIS-COM.bat now lands on the Public desktop
  (visible to operator or SupportUser) instead of SupportUser-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 11:56:00 -04:00
cproudlock
99deaa6882 CMM: add PC-DMIS 2026.1 desktop shortcut + taskbar pin (site-config)
The CMM pcProfile listed PC-DMIS 2016 and 2019 R2 in desktopApps/taskbarPins but
not 2026.1, so freshly imaged 2026 bays (CMM11/12) got no PC-DMIS shortcut. Add
the 2026.1 entries pointing at C:\Program Files\Hexagon\PC-DMIS 2026.1 64-bit\
PCDLRN.exe. Test-Path gated by the shortcut generator, so it is a no-op on
2016/2019 bays.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 20:49:55 -04:00
cproudlock
948d013539 preinstall: add VC++ 2015-2022 x64 redist (PC-DMIS 2026 vcruntime140_1.dll)
The x64 redist ladder in preinstall stopped at 2013, so 64-bit PC-DMIS 2026 had
no vcruntime140_1.dll (introduced in VC++ 2019/14.20) and PCDLRN.exe failed to
launch: "vcruntime140_1.dll was not found". The patched PC-DMIS MSI bypasses
Hexagon's Burn bundle, which would otherwise have pulled the runtime in - and the
existing 2010/2012 x64 entries (added for PC-DMIS 2016/2019) never got a
2015-2022 successor.

Add VC++ 2015-2022 x64 (vc_redist.x64.exe 14.44.35211, /install /quiet
/norestart), PCTypes ["*"], File-detected on vcruntime140_1.dll so it is
version-independent. Installer staged on the share at
pre-install/installers/vcredist/2022-x64/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 20:43:35 -04:00
cproudlock
6e218f3daf CMM/DODA: Install-DODA does the full unattended runtime+app install
The vendor GE_DODA_build5_Installer.exe is a multi-app NSIS bundler whose /S
silent mode is broken (nested sub-install -> MSI 1619); VM-confirmed it only
works interactively. So Install-DODA now drives the bundled components directly,
all silent:
  python-2.7.16 msi /qn  -> C:\Python27
  openjdk-1.8.0.232 msi /qn -> C:\Program Files\RedHat
  gs927w64.exe /S        -> Ghostscript 9.27
  vc_redist /quiet
  doda_build*.zip        -> C:\Apps\DODA  (+ writes .settings the zip lacks)
Then sets AFDA_INSTALL_DIR + Java PATH, precompiles .py->.pyc as admin, and
grants Users+Auth Users Full on C:\Apps\DODA so the locked-down operator can run
DovetailAnalysis.exe (writes .pyc/PreProcess/$TMP). Each step detects-and-skips;
idempotent. VM-validated end to end incl a non-admin operator-paths test.

Runtime binaries (python/jdk/gs/vc msis + doda zip) are staged on the share, not
committed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:54:15 -04:00
cproudlock
b5b644a360 CMM/DODA: restore on DODA bays + grant Full on whole C:\Apps\DODA
- Restore-CMM: drop the skip-on-doda gate. DODA bays now restore the
  config-version PC-DMIS + goCMM settings like any other bay (they have backups
  now; DODA itself installs separately to C:\Apps\DODA and is unaffected).
- sync-cmm-backups.sh: update the stale "do not back up DODA bays" note.
- Install-DODA.ps1: grant Users + Authenticated Users Full on the WHOLE
  C:\Apps\DODA (was PreProcess only) - DODA writes output/temp throughout the
  folder as the locked-down operator. /T covers PreProcess.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:22:06 -04:00
cproudlock
bc89ba1cf3 CMM: add Register-PCDMIS-COM.bat desktop helper for post-license COM regserver
PC-DMIS COM must be registered for goCMM to connect, but Pcdlrn.exe /regserver
no-ops until PC-DMIS is licensed - which is a manual post-image step
(clmadmin.exe). So we cannot register at imaging time. Instead 09-Setup-CMM
drops a self-elevating one-click helper on the SupportUser desktop (a
pre-existing profile; avoids 06-OrganizeDesktop's Public-desktop sweep). The
tech runs it after activating the license; safe to re-run. Falls back to Public
Desktop if the SupportUser profile is absent. Copy happens before the Step 3
staging cleanup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:10:44 -04:00
cproudlock
1487abdba5 CMM/DODA: enable on bays 4/10/11/12, fix PCTypes match, grant PreProcess ACL
Three fixes so DODA actually deploys on the four bays that need it:

- cmm-bay-config.csv: doda=yes for CMM4, CMM10, CMM11, CMM12 (was no on all
  bays). Drives doda.txt -> startnet pc-subtype.txt=doda -> the cmm-doda path.
- cmm-manifest.json: DODA entry PCTypes "cmm-doda" -> "gea-shopfloor-cmm-doda".
  The old value never matched: Test-PCTypeMatches builds the PC identity set as
  {gea-shopfloor-cmm, gea-shopfloor-cmm-doda, CMM} (the alias of the cmm type is
  bare CMM, no subtype variant), so "cmm-doda" was in no set and the entry was
  silently skipped. Must be the exact Type-SubType string.
- Install-DODA.ps1: grant Users + Authenticated Users Full on C:\Apps\DODA\
  PreProcess (icacls, SIDs, OI/CI inherit), applied every run. MergeFiles.exe
  writes there as the locked-down operator; without it the merge step fails.

Not yet pushed to the SFLD/enrollment share (server unreachable at commit time).
Not smoke-tested - no pwsh available here; logic-traced only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:35:39 -04:00
cproudlock
7af66575d0 CMM: gocmm-debug - capture the real startup NRE (PC-DMIS COM not registered)
debug.log from the bay shows the part group enumerates fine (14 .geop files
opened), then goCMM connects to PC-DMIS over COM and throws:

  System.ArgumentNullException: Value cannot be null. Parameter name: type
     at System.Activator.CreateInstance(Type type)
     at GEAE.Common.CMM.CMMInterfaces.PCDMIS.PCDMIS.<ConnectToSoftware>b__43_0()
  -> System.NullReferenceException at ConnectToSoftware(...)

Root cause: PC-DMIS automation server is not COM-registered, so
Type.GetTypeFromProgID returns null -> CreateInstance(null) throws -> the NRE
is the downstream symptom. Not part-group, not permissions, not calibration.

- PROBE 4: pull the .NET Runtime / Application Error crash stack for goCMM from
  the Application log so the next run captures the null in one shot.
- PROBE 5: (a) part-group UNC reachability; (b) PC-DMIS COM registration check
  (PCDLRN.* ProgID -> CLSID -> LocalServer32) that names the missing registration
  and the Pcdlrn.exe /regserver fix; plus install presence for context.
- .bat header documents the COM root cause.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 08:31:11 -04:00
cproudlock
8e11f0824a CMM: gocmm-debug probes the startup NRE (part-group match), checks real exe
goCMM matches the registry 'Selected Part Group' against the
ApplicationSettings.xml <PartGroup FullName> entries with a case-sensitive
compare. No match -> SelectedPartGroup null -> "Object reference not set to
an instance of an object" at start. This is a different failure from the
registry SecurityException the script already probes.

- PROBE 3: read the reg value (32-bit view) + every FullName in the XML,
  Ordinal-compare, and report exact / case-only / no-match / missing-XML.
  Case-only and no-match name the d441abd canonicalization fix as the remedy.
- Version check now looks for the real goCMM.exe, not the nonexistent
  GEAOperatorFriendlyInterface.exe (was a false MISSING).
- .bat header documents both failure modes it now diagnoses.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 08:08:00 -04:00
155 changed files with 18465 additions and 3450 deletions

8
.gitignore vendored
View File

@@ -118,3 +118,11 @@ playbook/shopfloor-setup/gea-shopfloor-partmarker/PartMarker/Mark/utilpassword.t
# HeatTreat per-machine DNC .reg exports (6601-6604) - contain DNC FtpPasswd
# credentials. Deployed via the enrollment share from the working tree.
playbook/shopfloor-setup/gea-shopfloor-heattreat/reg/*.reg
# SIC e10 controller config files carry the programmer/supervisor password
# in cleartext. Never commit them. See reference/e10/README.md
reference/e10/**/ConfigCtrl*
reference/e10/**/*.kind1
# GE part programs carry part numbers and marking content. Keep them out of
# git; they live in ~/pxe-images/e10-testing/partprograms/
reference/e10/partprograms/

95
docs/OWNERSHIP.md Normal file
View File

@@ -0,0 +1,95 @@
# Who owns what
Every bug that cost a day in this pipeline has had the same shape: two systems
both setting the same thing, and whichever ran last silently won. This file says
who owns each concern so the next person deletes the loser instead of adding a
third writer.
All four examples below are real, from 2026-08-06.
## Computer name - the PPKG owns it
The provisioning package declares:
```xml
<DNSComputerName>F%SERIAL%</DNSComputerName>
```
so a bay comes up as `F<serial>`.
`run-enrollment.ps1` used to also run `Rename-Computer -NewName "E$serial"`.
Both are pending renames; last writer wins at reboot. The script ran twice, and
its second run landed after the package had already queued `F579C144`, so the
bay came up `E579C144` with the package reporting no errors at all.
**Rule:** nothing in this repo renames a machine. If the naming convention
changes, it changes in the package.
## Drive letters - PESetup owns them
PESetup hardcodes `W:` in nine places - every copy destination, both DISM
offline sessions, `bcdboot`, `reagentc` - and creates it during its own disk
preparation.
`startnet.cmd` briefly had a volume finder that scanned for "the applied
Windows volume" and `diskpart`-assigned it to `W:`. On a re-image it found the
*previous* install, relabelled a partition PESetup was about to erase, and every
staging copy failed into a volume that no longer existed.
**Rule:** wait for `W:\Windows\System32\config\system` - the hive only exists
once the WIM apply has written it. Never run `diskpart` while PESetup is
running.
See `docs/PESETUP-INTERNALS.md`.
## Enrollment - the PPKG owns it, the orchestrator drives reboots
The SFLD package joins Entra using the BPRT token in
`0__Accounts_Azure.provxml`. A human then assigns the device category in Intune.
The vendor's `Start-BulkEnrollOrchestrator.ps1` has two branches. The normal one
aborts the package's own reboot, registers `AutoSecondReboot`, and drives the
Entra join to completion. The `-ManualFallback` one runs `sysprep /oobe /reboot`
- it is an interactive escape hatch for handing a machine back to OOBE, meant to
be triggered by a person.
The shopfloor unattend registered `-ManualFallback` as an at-logon scheduled
task. So OOBE completed, autologon fired, and four seconds later the machine
syspreped itself back to OOBE, losing the deployment chain permanently.
**Rule:** shopfloor bays enrol. Never wire `-ManualFallback` to anything
automatic.
## Kiosk URLs - GE-Enforce owns them
`plugins/geenforce/seed_display_scope.py` in shopdb-flask is authoritative:
| display-type.txt | route |
|---|---|
| `Dashboard` | `/shopdb/shopfloor` |
| `Lobby` | `/shopdb/tv` |
| `3DPrintRoom` | `/shopdb/parts-kiosk` |
Its dispatcher prefers the server-side role from Settings > Dashboard Defaults
(resolved by device IP), falls back to `C:\Enrollment\display-type.txt`, writes
the Startup shortcut itself, and **sweeps** any shortcut matching
`shopfloor-dashboard` or `/shopdb/`.
`site-config.json` also carries `edgeHomepage` per display type. Those are a
backstop for the window before the kiosk installer runs - a stale value there
gets deleted on the next enforce cycle rather than honoured.
**Rule:** if a kiosk points at the wrong page, fix the GE-Enforce scope first.
Keep `site-config.json` correct, but do not expect it to win.
## Repo vs share
The share is production; the repo is meant to describe it. Drift runs both ways -
live hand-edits nobody committed, and repo fixes never deployed.
`scripts/share-drift.py` classifies every mapped pair as `git-owned` (repo wins,
safe to push) or `unreconciled` (diverged, nobody has decided). It reports and
never writes. Run it before a build day; `scripts/preflight.py` covers the rest.
The unattends are `unreconciled` on purpose: the live copies are ~17 KB and the
repo copies ~12 KB, so pushing the repo would regress production.

278
docs/PESETUP-INTERNALS.md Normal file
View File

@@ -0,0 +1,278 @@
# PESetup.exe internals
What GE Image Setup actually does, start to finish. Written from the decompiled
assembly, not from observation, because several long-standing beliefs about this
tool turned out to be wrong and cost weeks of debugging.
**Version documented:** 4.0.0.17 (`Sources/PESetup.exe`, PE32+ native apphost
wrapping a .NET 6 single-file bundle, 468 embedded files).
**Version in production:** 4.0.0.20, as of 2026-08-06 — the media reports it in
its own log (`AppVersion: 4.0.0.20`). Everything in this document was verified
against a 4.0.0.20 run on bay 579C144 that day: the media drive resolved to `Z:\`,
`W:` was created by `PrepareDisk` and used for every copy destination, the fallback
unattend at `Deploy\FlatUnattendW10.xml` was the one loaded, and driver selection
matched `win11_optiplexd13mlk7020_a09.zip` by model. The decompiled detail below has
not been re-derived from the 4.0.0.20 binary, so treat exact line-level claims as
4.0.0.17 and the observed behaviour as current.
## How to re-derive this
The managed code is not directly readable - the outer PE has no managed
metadata, so ILSpy refuses it. Extract `PESetup.dll` from the bundle first:
```python
# .NET single-file bundle: signature is SHA-256 of ".net core bundle";
# the int64 EIGHT BYTES BEFORE it is the bundle header offset.
SIG = bytes([0x8b,0x12,0x02,0xb9,0x6a,0x61,0x20,0x38,
0x72,0x7b,0x93,0x02,0x14,0xd7,0xa0,0x32])
i = data.find(SIG)
header = struct.unpack_from('<q', data, i - 8)[0]
# header: uint32 major, uint32 minor, int32 count, 7-bit-prefixed bundle id,
# then (v2+) 4 x int64 deps/runtimeconfig, then uint64 flags.
# each entry: int64 offset, int64 size, (v6+) int64 compressedSize,
# byte type, 7-bit-prefixed path. Compressed entries are raw
# deflate (zlib.decompress(raw, -15)).
```
Then `ilspycmd -t <TypeName> PESetup.dll`.
## Paths it hardcodes
From `PESetup.Models.GlobalSettings`. All are relative to the MEDIA drive
(`Z:\`, whichever winpeapps share startnet mapped) unless stated.
```
ControlDir \Deploy\Control
ToolsDir \Deploy\Tools\GE
SoftwarePackagesDir \Deploy\Applications
HWPackagesDir \Deploy\HW_Apps
UnattendFile \Deploy\Tools\GE\XML\FlatUnattendW10.xml
UnattendFile2 \Deploy\FlatUnattendW10.xml (fallback)
DiskPartDir <exe dir>\DiskPart
ConfigSkipPackagesFile skip.json
ConfigDisableAutoStartFile disableauto.json
ExpirationDuration 30 days
AutoStartCountDown 31
MinRequiredSpaceWithoutCompression 128849018880 (120 GB)
```
**`W:` IS HARDCODED, EVERYWHERE.** Not derived, not configurable, not a
convention this project invented:
```csharp
CopyPackages destDir = "W:\\" + SoftwarePackagesDir
CopyHWPackages destDir = "W:\\" + HWPackagesDir
CopyTools destDir = "W:\\GE"
CopyDrivers destinationPath = "W:\\Drivers\\" + <zip name without extension>
ApplyImage destination = "W:\\"
CreateBCD "W:\\Windows\\System32\\bcdboot.exe", "W:\\Windows /l en-US"
ApplyPackages DismApi.OpenOfflineSession("W:\\")
CopyLogs tags -> "w:\\windows\\system32\\"
```
PESetup's own disk preparation is what makes W: exist. Any downstream script
that hunts for "the applied volume" is solving a problem this tool does not
have. `diskpart list volume` on a machine mid-image shows `Volume 0 W Windows
NTFS 237GB Healthy`.
## The five working steps
Keys as they appear in the log: `workingstep_gatherdata`, `workingstep_selectos`,
`workingstep_prepare`, `workingstep_copy`, `workingstep_apply`,
`workingstep_finalize`, `workingstep_reboot`.
### 1. GATHER DATA
Four operations, all automatic:
| Operation | What it establishes |
|---|---|
| `GatherDataMediaDrive` | `ImageInfo.MediaDrive`, e.g. `Z:\` |
| `GatherDataBootDrive` | `ImageInfo.BootDrive`, always `X:\` (WinPE RAM disk) |
| `GatherDataSelection` | BIOS version/type, serial, secure boot, OS, languages, model, **driver** |
| `GatherDataImageDisk` | picks the physical disk and logs its partitions |
`GatherDataSelection` is where most decisions are made:
1. **Secure boot is mandatory.** `SecurebootEnabled != 1` fails the step outright.
2. Reads `Control\LanguagePacks.json`.
3. `SystemHelpers.GetModel()` and `GetManufacturer()` off WMI.
4. Reads `Control\HardwareDriver.json` and `Control\hw_applications.json`.
NOTE: it reads **HardwareDriver.json**, not the `hw_drivers.json` that also
sits in that folder. Editing the wrong one changes nothing.
5. **Virtual platform check.** If ANY driver entry has a manufacturer containing
"virtual platform", the tool goes into virtual-only mode: it takes
`list[0]` as the driver and then REQUIRES the machine to look virtual
(model/serial/BIOS containing VIRTUAL, VMWARE, XEN, QEMU, VirtualBox, vmw).
On real hardware that is a hard failure. A stray "virtual platform" entry in
the catalogue therefore breaks imaging for every physical machine.
6. Otherwise `GetDriverByModel` (below).
7. If `Control\skip.json` exists, package installation is skipped.
### 2. SELECT OS
Operator-facing. Warns when the media holds an LTSC image ("99% of the time only
for ShopFloor"). Also shows days-to-expiry - the media expires 30 days after
build (`ExpirationDuration`).
### 3. PREPARE
| Operation | What it does |
|---|---|
| `PrepareDoDisking` | copies `<exedir>\DiskPart\diskpartEFI.txt` to `X:\diskpartEFI<n>.txt`, then `cmd /c DISKPART /S X:\diskpartEFI<n>.txt` |
| `PrepareUnattend` | loads the unattend, substitutes, saves to `X:\Unattend.xml` |
`PrepareUnattend` in detail:
```csharp
text = MediaDrive + UnattendFile; // \Deploy\Tools\GE\XML\FlatUnattendW10.xml
if (!File.Exists(text)) text = MediaDrive + UnattendFile2; // \Deploy\FlatUnattendW10.xml
xml.Load(text);
xml.InnerXml = xml.InnerXml.Replace("%serialnumber%", imageinfo.Serialnumber);
xml.InnerXml = xml.InnerXml.Replace("*arch*", arch);
SetPackages(arch, xml, imageinfo); // needs Control\Packages.xml
xml.Save("X:\\Unattend.xml");
```
Two tokens are substituted: `%serialnumber%` and `*arch*`. `SetPackages` merges
in servicing packages from `Control\Packages.xml` and `Control\PackageGroups.xml`.
The unattend that is USED is the one on the media at `Tools\GE\XML\` if present,
otherwise `Deploy\FlatUnattendW10.xml`. A shopfloor variant only takes effect if
it occupies one of those two paths.
### 4. COPY
Four operations. **None of them filters, and none of them reads the unattend.**
| Operation | Source | Destination | Rule |
|---|---|---|---|
| `CopyTools` | `Z:\Deploy\Tools\GE` | `W:\GE` | whole directory, recursive |
| `CopyPackages` | `Z:\Deploy\Applications` | `W:\Deploy\Applications` | whole directory, recursive |
| `CopyHWPackages` | `Z:\Deploy\HW_Apps` | `W:\Deploy\HW_Apps` | whole directory; **skipped if `IsVirtual` or `HWApps == null`** |
| `CopyDrivers` | one `.zip` chosen by model | `W:\Drivers\<zipname>` | **unzipped**, not copied |
`ExecuteInternalCopy` walks `GetDirectoriesRecursive(source, includeSubDirs:
true)` then `Directory.GetFiles(item, "*")`. Every file, every subdirectory. It
clears the read-only attribute on each copy.
**Consequences worth designing around:**
- Anything dropped into `Deploy\Applications` on the media lands on the target.
No manifest, no registration, no unattend reference required.
- The copy is FAIL-FAST. One exception on one file returns false and fails the
whole step with `copy_packages_error`. A locked or unreadable file in
`Applications` fails imaging, it does not get skipped.
- Progress is computed from total directory size up front, so bulky additions
visibly lengthen this phase.
### 5. APPLY
| Operation | What it does |
|---|---|
| `ApplyImage` | `install.wim` from `OperatingSystem.json`'s `destinationDir`, at `wimindex`, applied to `W:\` |
| `ApplyPackages` | DISM offline session on `W:\`, adds servicing packages; logs to `X:\ApplyOSPatch_<n>.log` |
| `ApplyLanguagePacks` | same pattern, `X:\ApplyLanguagePack_<n>.log` |
| `ApplyUnattend` | DISM offline session on `W:\`, applies `X:\Unattend.xml`; logs to `X:\ApplyUnattend.log` |
| `CreateBCD` | `W:\Windows\System32\bcdboot.exe W:\Windows /l en-US` |
| `CopyWinRE` | `reagentc /setreimage /path T:\Recovery\WindowsRE /target W:\Windows` |
All DISM work uses `W:\imagetemp` as scratch and deletes it afterwards.
### 6. FINALIZE
`CopyLogs`:
- `CopyTagFiles(ToolsDir)` - every `*.tag` from the tools dir to
**`w:\windows\system32\`**. This is how build/media tags reach the OS.
- `CopyLogFiles("X:\\")` and `CopyXMLFile("X:\\")` - the PESetup log and the
generated unattend are preserved.
## Driver selection, in full
This is the part most worth understanding, because a miss is nearly silent.
```csharp
// GatherDataSelection
List<HardwareDriversRootObject> list =
JSONHelpers.ReadJSON_Driver(MediaDrive + ControlDir + "\\HardwareDriver.json");
...
imageinfo.HWDriver = GetDriverByModel(list, imageinfo.Model);
if (imageinfo.HWDriver == null) {
// status = Warning, message "driver for [MODEL] not found"
return operationResult;
}
```
```csharp
private static HardwareDriversRootObject? GetDriverByModel(List<...> drivers, string model)
{
string modelFamily = "";
if (model.ToUpper().Contains("LATITUDE")) modelFamily = "Latitude";
if (model.ToUpper().Contains("OPTIPLEX")) modelFamily = "Optiplex";
if (model.ToUpper().Contains("PRECISION")) modelFamily = "Precision";
return drivers.Where(d => {
if (!string.IsNullOrEmpty(modelFamily) &&
!d.family.ToLower().Contains(modelFamily.ToLower())) return false;
foreach (string token in d.modelswminame.Split(','))
if (model.ToLower().Contains(token.ToLower())) return true; // SUBSTRING
return false;
}).ToList()?.FirstOrDefault();
}
```
Then:
```csharp
sourceFilePath = MediaDrive + HWDriver.destinationDir.Replace("*destinationdir*","")
+ "\\" + HWDriver.fileName;
destinationPath = "W:\\Drivers\\" + Path.GetFileNameWithoutExtension(sourceFilePath);
StartUnzipAsync(...)
```
### Four traps in that logic
1. **A miss is a WARNING, not a failure.** Imaging continues and the machine
comes up with no drivers - no NIC, no WiFi, so DNS fails and anything
network-dependent at first boot fails with it. Symptoms appear far from the
cause.
2. **Matching is substring, first match wins.** A token like `7020` matches any
model string containing 7020. Order in the JSON decides ties.
3. **The family filter knows only three Dell lines.** Anything else - MicroPCs,
NUCs, non-Dell - skips the filter and depends entirely on
`modelswminame` substrings being right.
4. **One "virtual platform" entry hijacks the whole catalogue** (see GATHER
DATA step 5) and fails every physical machine.
## What this means for startnet.cmd
Three beliefs encoded in `startnet.cmd` do not survive contact with the source:
- *"The applied volume might not be W:."* PESetup hardcodes W: in nine places
and creates it during disk prep. The volume finder, the diskpart
reassignment and the `W:` wait loop are machinery around a non-problem.
- *"We must copy our payload from the enrollment share after imaging."* Anything
in `Deploy\Applications` on the media is copied to `W:\Deploy\Applications`
by PESetup itself, and is readable at `C:\Deploy\Applications` at first boot.
- *"Display MicroPCs failed because the applied volume was not W:."* Worth
re-testing. A missing `HardwareDriver.json` match produces the same
end-state - no drivers, no network - via a completely different route, and
that route only logs a warning.
## Files PESetup reads from the media
```
Deploy\Control\HardwareDriver.json driver catalogue (NOT hw_drivers.json)
Deploy\Control\hw_applications.json per-model applications
Deploy\Control\OperatingSystem.json OS list, wim path, wimindex
Deploy\Control\LanguagePacks.json language packs
Deploy\Control\packages.json servicing packages
Deploy\Control\Packages.xml unattend package merge
Deploy\Control\PackageGroups.xml package grouping
Deploy\Control\Media.tag media identity
Deploy\Control\skip.json presence = skip package install
Deploy\Control\disableauto.json presence = disable autostart
Deploy\Tools\GE\XML\FlatUnattendW10.xml preferred unattend
Deploy\FlatUnattendW10.xml fallback unattend
```

View File

@@ -0,0 +1,695 @@
# Zero-touch, ShopDB-driven imaging - proposal
**Status:** proposal, nothing built
**Written:** 2026-08-06
**Author's note:** every claim about current behaviour below is from evidence
gathered on 2026-08-06 - decompiled binaries, live logs off bay 579C144, and the
live share. Where something is inferred rather than observed it says so.
---
## 1. What we want
Two ideas, one architecture.
**A. The image becomes thin.** It carries only what must happen at image time -
partition, apply the WIM, join Entra, install the enforce client. Everything else
(PC type, subtype, tool association, apps, settings) is data, pulled at first
boot from the ShopDB-Flask API and enforced continuously by GE-Enforce.
**B. Imaging becomes a job, not a menu.** Open a machine in ShopDB, click
*Create this PC*, pick the zeroed box that is sitting in WinPE waiting, and it
images start to finish with nobody touching the keyboard - reporting progress
onto that machine's record in real time.
Between them: no menus in WinPE, no per-type media, no decisions baked into text
files by whoever happened to be standing at the bay.
---
## 2. Why - what today actually costs
Today every decision is made in WinPE, written to files, and carried through a
chain where each link can fail silently:
```
startnet.cmd -> unattend (4 passes) -> FirstLogonCommands (18 orders)
-> ppkg -> DSC -> GE-Enforce
```
On 2026-08-06 alone, that chain produced:
| Failure | Root cause | How long it hid |
|---|---|---|
| Every build stopped at a dialog | one unattend `Path` at 676 chars against a 259 limit | ~1 day, and it masked the next two |
| Bays staged nothing, silently | a volume scan raced PESetup's `diskpart` and staged into a partition being erased | weeks (4 Display bays sat "green") |
| Deployment destroyed 4s after autologon | an at-logon task ran `-ManualFallback`, which is `sysprep /oobe /reboot` | until traced through Panther |
| Machines named `E<serial>` not `F<serial>` | our script re-applied a rename over the package's own | unknown |
| Six copies reported success having moved 0 bytes | unconditional `echo` after `robocopy` | weeks |
The pattern is identical in every case: **a decision made early, carried far, and
verified nowhere.** Moving decisions to a running, networked machine with an API
and a UI makes them recoverable - re-runnable, correctable, and observable.
---
## 3. What we already have to build on
This is not a greenfield project. Most of the mechanism exists.
### 3.1 ShopDB-Flask
- Assets keyed by serial; CMM bays, measuring tools, machine numbers already modelled
- Collector API: `POST /shopdb/api/collector/computers` (X-API-Key), ingests inventory by serial
- GE-Enforce plugin: scopes, manifests, **content-addressed payload store**
(`instance/geenforce/payloads/<sha256>`), `GET /api/geenforce/payload/<sha256>`
- Display scope (`plugins/geenforce/seed_display_scope.py`) already proves the pattern:
read a local subtype, prefer a **server-side role resolved by device IP**, pull
payloads over HTTPS, write the kiosk shortcut, sweep stale ones, self-heal each cycle
- Per-device role API: `GET /api/dashboarddefaults/display-role`
### 3.2 PXE server
- Per-bay state: `/var/log/pxe-imaging/<serial>.json` with stage history
- `winpe-status-push.ps1` already POSTs stage updates from WinPE
- Data-driven menus already: `menu.json` on the share, rendered by
`select-shopfloor-type.ps1`, edited by the webapp
- Imaging dashboard with per-bay tiles
- As of today: staging verification, imaging-log harvest to
`enrollment/imaging-logs/<serial>/`, `preflight.ps1` at the bay,
`preflight.py` + `lint-unattend.py` + `lint-driver-catalogue.py` +
`share-drift.py` on the server
### 3.3 Hardware
- Second NIC already present and unused: `enp0s31f6` (onboard, `e1000e`, currently down)
- PXE LAN currently on a USB adapter, `enx34c8d6b11010`
- That adapter was silently capped at USB 2.0 (327 Mb/s measured); moved to a
USB 3 port on 2026-08-06 and now runs at line-rate gigabit (**937 Mb/s measured**)
---
## 4. Target architecture
### 4.1 Three (or one) pre-imaged types
Proposed: **Displays, Inspection, Shopfloor** as pre-imaged stock, with the
subtype chosen later by the user at first boot.
**Open question worth settling early:** if the base image is identical and all
configuration comes from the API, what actually differs between the three?
Drivers are selected by PESetup per *model*, not per type. Unless disk layout or
a must-be-offline application differs, **one image** is better - three media trees
mean three things to keep in sync, and drift is what caused the day-long outage.
### 4.2 First-boot selection
A friendly PS1 runs when no PC type is recorded yet:
```
Inspection -> CMM | Genspect | Wax Trace | Keyence
└─ CMM / Keyence / WaxTrace -> which measuring tool?
(list served from ShopDB assets, not a CSV)
Displays -> Lobby | Dashboard | 3D Print Kiosk
Shopfloor -> Collections | No Collections | Common | Heattreat | Part Marker | ...
```
The tool selection is the important half: it drives PC-DMIS version, FormTracePak
version, DODA flag, NTLARS/eDNC registry, UDC settings, machine number.
**The network constraint that shapes this.** ShopDB is only reachable once the
bay has joined the **AESFMA wifi SSID** - not on the imaging LAN, and not
necessarily at first boot. So a first-boot selection UI cannot assume the API is
there. It must either work offline and sync later, or wait. Confirmed on
579C144: the bay held `172.16.9.81` and `172.24.19.142`, neither in the
production ranges, and `sync_intune` sat retrying every 30 seconds.
**The pattern that solves it, already built and worth reusing.**
`Install-ShopdbKiosk-WhenOnline.ps1` is a SYSTEM task armed at imaging that does
nothing until ShopDB answers, then acts once, verifies, and unregisters itself.
Any step in the new architecture that needs the API - selection sync, config
pull, enrollment completion - can use the same shape rather than assuming
connectivity.
**Design points:**
- **Server is the source of truth.** The selection POSTs to ShopDB keyed on
serial; the local file is a cache. A mis-selection is then fixable from a
browser, not by walking to the bay.
- **Re-runnable.** Available from the Start menu, shows what was chosen and what
was installed. People pick wrong; that must not mean re-imaging.
- **Audited.** ShopDB records who chose what and when - that is what separates
"misconfigured" from "chose wrong" six months later.
- **Do not require the LAPS password.** Handing a shop-floor user the local admin
password to run a selection dialog is a real control change, and the app runs
elevated. Prefer a SYSTEM-run UI on the logon desktop, or a kiosk shell.
### 4.3 Per-machine configuration records
Today a machine's identity is scattered:
| What | Where today | Size |
|---|---|---|
| eDNC / NTLARS registry | `_ntlars-backups/` - 147 per-bay `.reg` files | ~1.5 MB total |
| WaxTrace bay config | `bay-config.csv` - 15 bays, 7 FTPak versions, per-bay user ID | small |
| CMM bay config | `cmm-bay-config.csv` -> version.txt, doda.txt | small |
| UDC settings | `pre-install/udc-backups/`, per-bay | ~240 MB per bay |
| Machine number | `machine-number.txt` written by startnet | trivial |
Every bay gets **all** of it staged, then picks its own.
Proposed: one record per machine in ShopDB.
```
ShopDB asset (key: serial and/or machine number)
├─ pctype + subtype -> selects the GE-Enforce scope
├─ tool association -> CMM bay, Keyence model, FTPak version, DODA
├─ machine number -> UDC / eDNC naming
└─ payload refs (sha256) -> NTLARS .reg, UDC backup, per-bay settings
```
**Reference payloads, do not embed them.** NTLARS `.reg` files carry binary
values and UDC backups are hundreds of MB. GE-Enforce already has a
content-addressed blob store with an HTTP fetch endpoint - the record names the
payload, the store holds it.
This also removes a live bug class. `Restore-UDCData.ps1` and
`Update-MachineNumber.ps1` each mount SMB backup shares on their own drive
letters, which collided with GE-Enforce's `W:` and killed the manifest entry
running next (fixed in `66c24b5` by moving them to `R:` and `N:`). Payloads over
HTTPS remove drive-letter juggling entirely.
Migration is a one-time import of two CSVs and 147 `.reg` files. A script, not a
project.
---
## 5. Zero-touch: ShopDB-triggered imaging
### 5.1 Flow
```
1. Zeroed PC, boot order = IPv4 only. PXE boots into WinPE.
2. WinPE reports serial + MAC + model, then WAITS.
3. ShopDB shows it under "awaiting assignment".
4. Operator opens the target machine -> "Create this PC" -> picks the waiting box.
5. WinPE polls, receives a job:
{ pctype, subtype, tool/bay, tenant, purpose, target asset id }
and images with NO menus.
6. Progress streams to that machine's record in real time.
7. On completion the PC binds to the asset record.
```
Every menu in `startnet.cmd` becomes a field in the job. If no job exists, it
falls back to today's interactive menu and displays its own serial so one can be
created.
### 5.2 Safety - this is a remote-triggered disk wipe
Clicking a web button destroys a machine, and the target is chosen from a list.
The nightmare is picking the wrong row.
**Mitigation:** the waiting PC displays a short code on screen; the job cannot be
claimed unless that code matches what the operator confirms in ShopDB. Cheap, and
it makes wrong-machine wipes structurally hard rather than merely unlikely.
Additionally: a job may only target a machine currently in the waiting state, and
the wipe must be logged against both the operator and the asset.
### 5.3 Real-time record updates
Each phase already produces data; it simply is not joined up.
| Phase | Existing source | Lands on the record as |
|---|---|---|
| PXE boot | dnsmasq lease + WinPE check-in | serial, MAC, model, "imaging started" |
| WIM apply | `winpe-status-push.ps1` | live stage / progress |
| Staging | `winpe-staging.log` + harvested bundle | what payload actually landed |
| First boot | collector API | CPU, RAM, disk, OS build |
| Enrollment | `criticalChecks.json`, `TokenMatch.json` | Entra join state, tenant, purpose |
| Steady state | GE-Enforce report | installed apps, drift, self-heal events |
End state is one timeline per machine: *imaged 14:32 -> staged
gea-shopfloor-cmm, bay CMM4 -> enrolled GCCH_SH -> PC-DMIS 2019 R2 installed ->
enforcing clean since*.
**Two rules so it does not rot:**
1. **Push, best-effort.** If ShopDB is unreachable the bay must still image.
Queue events on the PXE server and drain them; the harvested logs are the
durable local copy.
2. **One writer per field.** Imaging status from the PXE server, inventory from
the collector, app state from GE-Enforce. Two writers on one field is the
exact bug that ate 2026-08-06.
---
## 6. Network design
### 6.1 Dual-homed PXE server
- **PXE side** - isolated `172.16.9.0/24`, dnsmasq DHCP bound to that adapter
- **GE side** - corporate, reachable from anywhere; carries ShopDB API traffic,
the job queue, status pushes, and (if wanted) the BPRT token fetch
The onboard `enp0s31f6` is free for this today.
### 6.2 Security prerequisites - these gate the whole thing
Checked 2026-08-06:
**Already safe.** dnsmasq has `listen-address=172.16.9.1` + `bind-dynamic`, so it
binds only to the interface holding that address. DHCP will **not** leak onto a
GE-side NIC. This was the failure I most expected and it is already handled.
**Not safe yet:**
```
smb.conf no `interfaces =` / `bind interfaces only` -> Samba listens on ALL interfaces
ufw every rule is ALLOW IN Anywhere, not interface-scoped:
22/tcp SSH (account is pxe/pxe)
445/tcp shares (pxe-upload, password in scripts)
69/udp TFTP
9009,9011 webapp
```
The moment a GE-side interface comes up, all of that is corporate-reachable.
**Required before dual-homing:**
1. Scope every UFW rule to the PXE interface (`ufw allow in on <pxe-if> ...`)
2. `interfaces = <pxe-if>` + `bind interfaces only = yes` in `smb.conf`
3. Expose deliberately on the GE side only what must be - realistically the
webapp, behind real authentication
4. Reconsider SSH credentials if the host becomes corporate-reachable
*(Correction to an earlier note: the shares are **not** guest-accessible -
`guest ok = no` on every share, with `map to guest = bad user`. They require the
`pxe-upload` account. That account's password is in scripts, so it is not a
control once 445 is corporate-reachable.)*
### 6.3 What dual-homing unlocks
- Job queue sync and live status without the share as intermediary
- BPRT token fetch (`mcl.dwcdn.geaerospace.com`) reachable at imaging time
- Possibly the Entra join during imaging rather than after re-cabling - see §8.2
---
## 7. The PESetup question
### 7.1 Autostart does not apply to our media
`AutoStartCountDown = 31` exists in the decompiled settings, and
`disableauto.json` - whose *presence* disables autostart - is absent from the
media. On the surface that suggests a 31-second countdown, and the observed log
is consistent with an operator simply clicking before it fired:
```
11:30:07.74 'Selection View' started (LTSC warning displayed)
11:30:18.63 Btn 'Next' pressed.
```
**However:** per the team's operational experience, **LTSC builds cannot
autostart**. The SELECT OS step warns when the media holds an LTSC image
("99% of the time only for ShopFloor. Are you sure you want 'LTSC'?") and that
confirmation is mandatory regardless of the countdown. Our media is
`Windows 11 LTSC 24H2` (`OperatingSystem.json`, build 26100, id 20), so every
image we produce hits it.
Consequence: **the countdown is not a route to zero-touch for us.** The 40-second
hands-off test is still worth doing once to confirm the behaviour first-hand and
record it - but plan on the answer being "it waits".
That promotes §7.2 from a contingency to the actual decision.
### 7.2 Re-implementation - now the likely path, and tractable
Nothing in the mechanism is proprietary - only the data, which we already own and
already parse.
| PESetup step | Equivalent |
|---|---|
| `PrepareDisk` | `diskpart /s diskpartEFI.txt` |
| `PrepareUnattend` | token substitution (`%serialnumber%`, `*arch*`) + `Packages.xml` merge |
| `CopyTools` / `CopyPackages` / `CopyHWPackages` | robocopy |
| `CopyDrivers` | select by model from `HardwareDriver.json`, unzip |
| `ApplyImage` | `DISM /Apply-Image` per `OperatingSystem.json` |
| `ApplyPackages` / `ApplyLanguagePacks` | `DISM /Add-Package` |
| `ApplyUnattend` | `DISM /Apply-Unattend` |
| `CreateBCD` / `CopyWinRE` | `bcdboot`, `reagentc` |
A few hundred lines of PowerShell. We would also get to fix the traps documented
in `docs/PESETUP-INTERNALS.md`: substring first-match-wins driver matching, a
family filter that knows only Latitude/OptiPlex/Precision, and a driver miss
being a **warning** rather than a failure.
**The cost is ownership, not effort.** `Release.json` points at MCL auto-update
URLs, so GE expects this media to self-update. Diverging means tracking their
format changes indefinitely, plus a likely compliance conversation about the
mandated imaging tool. Since the LTSC gate means autostart will not save us
(§7.1), this is a decision to take deliberately rather than a fallback.
### 7.3 What we would keep either way
Image-time preflight stays valuable regardless: secure boot (PESetup hard-fails
without it), >=120 GB disk (`MinRequiredSpaceWithoutCompression`), driver match
for the model, media age (30-day expiry). Already built as
`playbook/scripts/preflight.ps1`.
---
## 8. Enrollment
### 8.1 How it works today
- Six MCL packages on the media: `PERS` / `SH` / `LOAN` x `GCCH` / `RoW`, plus
the SFLD package for shopfloor
- `Start-BulkEnrollOrchestrator.ps1` asks tenant + purpose via
`Select-PCConfig.ps1` (a GUI), then applies the matching package
- `Insert-BPRTToPPKG.ps1` fetches an **encrypted token table** from
`mcl.dwcdn.geaerospace.com`, decrypts with a static passphrase
(PBKDF2 310k / SHA-256 / AES-CBC), matches on **Tenant AND Purpose**, injects
the token and stamps the real expiry into the filename
(`Exp_XXXXXXXX` -> `Exp_yyyyMMdd`)
- The package itself sets `<DNSComputerName>F%SERIAL%</DNSComputerName>` and
performs the Entra join
- A human then assigns the device category in Intune
As of 2026-08-06 the tenant/purpose choice can be pre-seeded at the PXE menu
(`C:\Enrollment\pc-config.txt`, consumed by a shim at the path the orchestrator
already calls). Injection deliberately stays at first logon - it needs internet.
### 8.2 The constraint that shapes pre-imaged stock
The BPRT token is stamped into the package with an expiry. A PC imaged, powered
off, and left in a cupboard for three months has a **dead token** and will never
join - and the failure looks identical to "not on the production network yet",
because `Entra ID Joined: false` right after imaging is normal.
Three options, pick deliberately:
1. **Join at imaging time** - requires the production network at the bench
(which dual-homing could provide), and pre-imaged stock is then already joined
2. **Join at first power-on** - cleaner logistically, but stock has a shelf life
bounded by token expiry
3. **Re-inject on demand** - first boot detects a dead token and fetches a fresh
one; needs the machine to reach the token blob
Whichever is chosen, the *silent* failure must be closed: a check that
distinguishes "token expired" from "no network yet".
---
## 9. Ownership model
Four times on 2026-08-06 two systems owned one thing and the last writer won
silently. A clean slate is the moment to fix that. See `docs/OWNERSHIP.md`.
| Concern | Owner | Not |
|---|---|---|
| Computer name | the provisioning package (`F%SERIAL%`) | any script in this repo |
| Drive letters during imaging | PESetup (`W:`, hardcoded 9 places) | anything running `diskpart` concurrently |
| Entra enrollment | the package + orchestrator | `run-enrollment.ps1` |
| Kiosk / display targets | GE-Enforce scope | `site-config.json` (backstop only) |
| Imaging status | PXE server | the collector |
| Inventory | collector API | the PXE server |
| Installed app state | GE-Enforce reports | anything else |
| Job queue | **ShopDB** (owns assets) | the PXE webapp (executor only) |
---
## 9a. Design rules learned the hard way (2026-08-06)
A day of fixing the current pipeline produced five rules. They are cheap to
honour in a new design and expensive to retrofit.
### Absent and empty are different
Four separate scripts tested config as
`$null -ne $cfg -and $cfg.Count -gt 0`, so an explicitly empty list fell through
to a hardcoded default. A Display kiosk configured with
```json
"desktopApps": [], "startupItems": [], "taskbarPins": []
```
was given UDC, eDNC, NTLARS, WJ Shopfloor, Defect Tracker and Plant Apps -
**configuring "none" produced "everything"**. Plant Apps launched
`msedge --new-window` on a screen with no keyboard.
Measured, not assumed: an empty JSON array round-trips as `Object[]` with
`Count = 0`, and an absent key as `$null`. They are distinguishable; the code
simply conflated them.
*Rule: in the API model, "no items" and "not configured" must be different
values on the wire, and the default for an unrecognised or missing scope is
NOTHING, not a fallback set.*
### Deny by default, always
`preinstall.json` had 16 of 21 entries at `PCTypes: ["*"]`, so a kiosk installed
Adobe, OpenText, Defect Tracker, the serial drivers and twelve legacy VC++
redistributables. The SMB manifest had 18 of 25 entries unfiltered. In both
cases the filter worked perfectly - it simply was not applied.
*Rule: every payload declares which PC types it is for. An undeclared payload
installs nowhere and fails validation, rather than installing everywhere.*
### Gate at the point of action, not the call site
`Run-ShopfloorSetup.ps1` gated both the S: mapper and the machine-number prompt
on PC type. Both registered anyway on a Display, with no "Skipping" line in the
log - something in the finalization phase reaches those registrars past the
call-site gate. The fix was to gate inside each registrar.
*Rule: authority checks belong with the thing being done. A caller-side check is
a convenience, never the control.*
### The image itself carries configuration
`WJ Shopfloor.lnk`, dated April, sits in the all-users Startup folder of every
bay - **inside the WIM**. No script change removes it, and no amount of thinning
the pipeline touches it.
*Rule: "thin image" must include auditing what is baked into the WIM, not just
what the pipeline adds afterwards.*
### The provisioning package is a hard boundary
These are installed by the ppkg, not by anything PXE controls:
```
Google Chrome (+2 updater tasks) RealVNC Tanium
CyberArk EPM .NET 3.5 Report IP Sysinternals Autologon DSC
PowerShell 7 x64 AND x86
```
*Rule: thinning the image does not thin the package. Anything that must not be
on a kiosk and comes from the ppkg needs a package change or an explicit
post-install removal - it cannot be solved by scoping our own payloads.*
---
## 10. Phased delivery
Each phase is useful standing alone. No phase requires the next.
**Phase 0 - settle the unknowns (hours)**
- Confirm the LTSC autostart gate first-hand (one bay, hands off, 40 seconds)
and record it - expected to wait, per operational experience
- Decide: re-implement the imaging step, or accept one click per build
- Decide: one image or three
- Decide: join at imaging time, at first power-on, or re-inject
**Phase 1 - visibility (small)**
- WinPE reports serial / MAC / model on boot; "awaiting assignment" list in the webapp
- Relay imaging status to ShopDB over the GE side
- *Useful immediately: you can see what is booting and what it did.*
**Phase 2 - dual-home safely (small, gating)**
- Interface-scope UFW, bind Samba, bring up `enp0s31f6`
- Nothing else proceeds safely until this is done
**Phase 3 - job queue**
- Job model + API in ShopDB (create / claim / complete), with the on-screen
confirmation code
- WinPE polls once where the menu is today; falls back to the menu if no job
**Phase 4 - first-boot selection**
- Selection UI, server-backed lists, POST the choice, re-runnable
- Retire the WinPE sub-menus for subtype and tool association
**Phase 5 - per-machine records**
- Import the CSVs and `.reg` files into ShopDB with payload refs
- Retire `bay-config.csv`, `cmm-bay-config.csv`, and staging all 147 `.reg` files
to every bay
**Phase 6 - thin the image**
- Remove per-type media (`_media/<pctype>`), the staging block, the Office ppkg
variants, the CMM bay picker, the WaxTrace ISO cherry-pick
- WinPE's job becomes: partition, apply, join, install enforce client, reboot
---
## 11. Risks
| Risk | Why it matters | Mitigation |
|---|---|---|
| Remote-triggered wipe hits the wrong machine | destructive, irreversible | on-screen confirmation code; job may only target a waiting machine; log against operator and asset |
| ShopDB becomes a single point of failure | today a bay configures from the share with ShopDB down | GE-Enforce ETag / last-known-good cache; explicit failure UI, never a silent stall |
| Dual-homing exposes the PXE server | SSH `pxe/pxe`, SMB, TFTP currently `ALLOW IN Anywhere` | Phase 2 gates everything |
| BPRT expiry on shelf stock | silent, indistinguishable from "no network yet" | pick a join strategy (§8.2) and add an explicit expiry check |
| Selection UI becomes the new single point of correctness | wrong subtype = wrong PC-DMIS, wrong bay config | re-runnable, server-corrected, audited |
| Re-implementing PESetup | ongoing MCL format tracking + a likely compliance conversation | scope it against "one click per build" - zero-touch is the only thing that needs it |
| Big payloads over the corporate network | PC-DMIS, FormTracePak ISOs (~2 GB each), Keyence | GE-Enforce supports `smb` / `http` / `inline` per entry - decide per payload |
| Three images drift apart | drift caused the 2026-08-06 outage | prefer one image; `share-drift.py` guards what remains |
| API unreachable at first boot | ShopDB needs AESFMA; the imaging LAN cannot reach it | wait-for-network task pattern (§4.2); never assume connectivity |
| ppkg-delivered apps on a kiosk | Chrome, RealVNC, Tanium etc. are outside PXE control | package change or explicit post-install removal - scoping our payloads cannot fix it |
---
## 12. Open questions
1. One image or three? What genuinely differs between them?
2. Join at imaging time, at first power-on, or re-inject on demand?
3. Is the LTSC confirmation truly unskippable? (expected yes - it decides
whether zero-touch requires re-implementing the imaging step)
4. Is replacing the GE-mandated imaging tool acceptable, if it comes to that?
5. Does the selection UI run as SYSTEM (no LAPS password), or as an admin user?
6. Which payloads stay on SMB and which move to HTTPS?
7. Who operates the job queue day to day - is "Create this PC" a technician
action, or does it need approval?
---
## 13. Where everything referenced here lives
### 13.1 This repo (`/home/camp/projects/pxe`)
| Path | What |
|---|---|
| `docs/PESETUP-INTERNALS.md` | decompiled imaging tool behaviour; the source for every PESetup claim above |
| `docs/OWNERSHIP.md` | who owns what, with the 2026-08-06 collisions |
| `playbook/startnet.cmd` | the WinPE script this proposal thins out - menus, staging, verify, log harvest |
| `playbook/scripts/preflight.ps1` | bay-side checks (secure boot, disk, driver match, media age) |
| `playbook/shopfloor-setup/run-enrollment.ps1` | applies the SFLD package via `provtool` |
| `playbook/shopfloor-setup/Run-ShopfloorSetup.ps1` | post-boot setup; the enrollment-model comment is here |
| `playbook/shopfloor-setup/Fetch-StagingPayload.ps1`, `Verify-And-Heal-Staging.ps1` | first-logon self-heal; read `C:\Enrollment\fetch-source.txt` |
| `playbook/shopfloor-setup/BPRT/Select-PCConfig.ps1` | the pre-seed shim (vendor GUI preserved as `-vendor.ps1` on the share) |
| `playbook/shopfloor-setup/BIOS/{check-bios.cmd,models.txt}` | firmware matching |
| `scripts/preflight.py` | runs every server-side lint in one command |
| `scripts/lint-unattend.py` | schema limits: Path 259, CommandLine 1024, Description 256 |
| `scripts/lint-driver-catalogue.py` | reimplements `GetDriverByModel` |
| `scripts/share-drift.py` | repo vs live share, git-owned vs unreconciled |
| `playbook/shopfloor-setup/Collect-ImagingDiagnostics.ps1` | one-pass bay diagnostics - identity, enrollment, installed apps, all four autostart surfaces, GE-Enforce reachability |
| `scripts/build-pctype-media.py` | per-PCTYPE media views (retired by Phase 6) |
### 13.2 PXE server (`pxe@172.16.9.1`)
| Path | What |
|---|---|
| `/srv/samba/winpeapps/<image>/Deploy/Control/` | `HardwareDriver.json`, `OperatingSystem.json`, `Media.tag`, `Release.json` |
| `/srv/samba/winpeapps/<image>/Deploy/FlatUnattendW10.xml` | the answer file that actually boots machines |
| `/srv/samba/winpeapps/_shared/Applications/BPRT/` | the six MCL packages, `Insert-BPRTToPPKG.ps1`, `Start-BulkEnrollOrchestrator.ps1`, `Select-PCConfig*.ps1` |
| `/srv/samba/winpeapps/_shared/BIOS/` | firmware EXEs + `models.txt` (served as the `winpeapps_bios` share) |
| `/srv/samba/winpeapps/_media/<pctype>/` | per-PCTYPE media views |
| `/srv/samba/enrollment/scripts/` | `run-enrollment.ps1`, `preflight.ps1`, `winpe-status-push.ps1` |
| `/srv/samba/enrollment/shopfloor-setup/` | per-PC-type trees, `menu.json`, `_ntlars-backups/` (147 `.reg`) |
| `/srv/samba/enrollment/shopfloor-setup/gea-shopfloor-waxtrace/bay-config.csv` | 15 bays, FTPak versions |
| `/srv/samba/enrollment/installers-post/cmm/cmm-bay-config.csv` | CMM bay -> version, DODA |
| `/srv/samba/enrollment/pre-install/udc-backups/` | per-bay UDC settings |
| `/srv/samba/enrollment/ppkgs/` | the SFLD package (8.2 GB) |
| `/srv/samba/enrollment/imaging-logs/<serial>/` | harvested imaging logs (added 2026-08-06) |
| `/var/www/html/win11/sources/boot.wim` | the booted WinPE image |
| `/var/log/pxe-imaging/<serial>.json` | per-bay stage history |
| `/opt/pxe-webapp/` | Flask webapp (unit `pxe-webapp`, :9009) |
| `/etc/dnsmasq.conf`, `/etc/samba/smb.conf` | the binding config §6.2 depends on |
### 13.3 shopdb-flask (`/home/camp/projects/shopdb-flask`)
| Path | What |
|---|---|
| `plugins/geenforce/seed_display_scope.py` | the pattern this proposal generalises - subtype map, server role by IP, sweep |
| `plugins/geenforce/client/` | `ShopdbEnforceClient.psm1`, `Invoke-ShopdbEnforce.ps1` |
| `shopdb/core/api/` | collector endpoint, dashboard-defaults display-role |
### 13.4 Evidence behind the claims in this document
| Path | What it shows |
|---|---|
| `/home/camp/pxe-images/now/pesetuplog.txt` | PESetup 4.0.0.20 run - media drive, disking, copy, driver selection, the `Btn 'Next'` timing in §7.1 |
| `/home/camp/pxe-images/now/debug.txt` | the WinPE console for the failed volume-finder run |
| `/home/camp/pxe-images/pxe2/Panther/` | `setupact.log` / `setuperr.log` naming the unattend `Path` failure |
| `/home/camp/pxe-images/debug2/` | the autologon registry state and the sysprep-loop timeline |
| `/home/camp/pxe-images/debug3/Logs/BPRT/` | the 13 package components, `criticalChecks.json`, `packageInfo.json` |
| `/home/camp/pxe-images/debug3/Logs/PPKG/` | the package itself, `provisioning-sessions.json`, the diagnostics evtx |
| `/home/camp/pxe-images/debug3/Logs/enrollment.log` | the double run and the rename race in §9 |
| `/home/camp/pxe-images/debug3/Logs/FilteredReportIP.log` | proves the bay had no route to Entra (§8.2) |
### 13.5 On-bay locations (for anyone diagnosing one)
```
C:\Enrollment\ staged payload, pc-type.txt, display-type.txt,
pc-config.txt, fetch-source.txt, winpe-staging.log
C:\Deploy\Applications\BPRT\ the packages + orchestrator, copied by PESetup
C:\Logs\BPRT\<Component>\ per-component package logs (UTF-16)
C:\Logs\BPRT\criticalChecks.json PPKG matched / Entra joined / OOBE execution
C:\Logs\PPKG\ provisioning diagnostics + session state
C:\Logs\SFLD\ DSC deployment, sync_intune, shopfloor-setup
C:\Logs\enrollment.log run-enrollment.ps1
C:\Windows\Panther\ setupact.log, setuperr.log, unattend.xml, PESetup*.log
C:\Windows\Panther\UnattendGC\ oobeSystem pass
```
### 13.5a Gotchas any new tooling will hit
- **`HardwareDriver.json` cannot be read by `ConvertFrom-Json`.** It carries both
casings of the same fields (`fileName`/`FileName`, `destinationDir`/
`DestinationDir`) and PowerShell rejects the document - on 5.1 *and* 7.
`-AsHashtable` is PS6+ and WinPE runs 5.1. Extract fields by regex, or parse in
Python where duplicate keys are tolerated.
- **`Compress-Archive` caps at 2 GB**, and `run-enrollment` harvests the 8 GB
provisioning package into `C:\Logs\PPKG`, so any log collection must exclude
`*.ppkg`.
- **Backslash-vs-forward-slash**: PowerShell Core normalises `\` to `/` on Linux,
so `Join-Path` with Windows separators works cross-platform - handy for testing
bay scripts on the dev box.
### 13.6 How to re-derive things
```bash
# read a file off the share
sshpass -p pxe ssh -o StrictHostKeyChecking=no pxe@172.16.9.1 'cat <path>'
smbclient //172.16.9.1/enrollment -U pxe-upload%pxe -c 'ls'
# what is actually inside the booted WinPE
sudo wimextract /var/www/html/win11/sources/boot.wim 1 \
/Windows/System32/startnet.cmd --dest-dir=/tmp/x
sudo wimdir /var/www/html/win11/sources/boot.wim 1 | grep -i <name>
# a .ppkg is a WIM - pull out the spec without extracting 8 GB
7z l <pkg>.ppkg
7z e <pkg>.ppkg -o<dir> "Multivariant/0/customizations.xml" \
"Multivariant/0/Prov/RunTime/0__Accounts_Azure.provxml"
# provisioning diagnostics (python-evtx is installed)
python3 -c "from Evtx.Evtx import Evtx;
[print(r.xml()) for r in Evtx('Provisioning-Diagnostics-Admin.evtx').records()]"
# decompile PESetup - the bundle-extraction recipe is in PESETUP-INTERNALS.md
ilspycmd -t <TypeName> PESetup.dll
# validate before deploying
./scripts/preflight.py # every server-side lint
./scripts/share-drift.py --diff # repo vs live
```
### 13.7 The 2026-08-06 commits this document draws on
```
dfa026b lint-driver-catalogue c7b1769 build-pctype-media
049dd7b startnet per-PCTYPE media d5f7abc BIOS 7020 family
385acfe lint-unattend 2b6189f startnet W: wait + honest reporting
d04865d PESetup version + LogonCount b96ff7e retire shopfloor-dashboard route
36be60e run-enrollment stops renaming 8c21282 staging verify + log harvest
d2200e8 preflight (bay + server) 68df59e OWNERSHIP.md + share-drift
9d51c0b purpose menu + 3D kiosk a0aceb4 PXESERVER consolidation
bb08392 download-drivers case fix
```

View File

@@ -0,0 +1,296 @@
# MTConnect follow-up runbook
Written 2026-08-11. Everything here needs a machine this box cannot reach - the
SFLD share, the middle-man PC, or a shopfloor bay - so it is yours to run.
Work in the order given. Part 1 changes what future installs get; parts 2 and 3
fix bays that are already wrong; parts 4 and 5 are questions to answer before
any more code is written.
### Identify every bay by machine number
The machine number is the only identifier that stays put. A PC gets replaced or
re-imaged and its name changes; the machine it sits at does not. Both machine-to-PC
sources used to build this document are point-in-time snapshots and will already
have drifted.
Every PC name below came from `mtconnect-hostname-machine-map.csv` (12 June),
the `Shop Floor PC Name` column of `West Jefferson_Machines.csv`, or the variant
captures. Treat them as "this is where the fault was last seen" - useful for
finding the bay, never for deciding what to write into it.
**The bay knows its own number.** In order of authority:
```powershell
# 1. eDNC registry - what GE-Enforce trusts post-imaging
(Get-ItemProperty 'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General' -Name MachineNo).MachineNo
# 2. the enrollment file imaging writes
Get-Content C:\Enrollment\machine-number.txt
# 3. UDC
(Get-Content C:\ProgramData\UDC\udc_settings.json -Raw | ConvertFrom-Json).GeneralSettings.MachineNumber
```
Or dot-source the lib and ask for both at once:
```powershell
. C:\GE Aerospace\lib\Update-MachineNumber.ps1
Get-CurrentMachineNumber # @{ Udc = ...; Ednc = ... }
```
**ShopDB is the live PC-to-machine record**, maintained by the collector rather
than by hand, so it beats any CSV:
```
https://tsgwp00525.wjs.geaerospace.net/shopdb/api/computers/by-hostname/<HOSTNAME>
```
If those disagree with each other, or with the machine plate, stop and resolve
that first - a rename applied to the wrong number is worse than the mismatch
you started with.
---
## Part 1. Push the Fanuc adapter 3.25.3 to the SFLD share
The staged bundle here is already on 3.25.3.0 (`sha256` starts `6e46dcd51affa9e0`).
The live share is not. Until this is copied, new installs still get 3.23.0.0.
**1.1** From the middle-man PC, with `Z:` on the dev share:
```
Z:\mtconnect-bundles\fanuc\Adapter\
```
should contain `FanucCppAdapterWinX64.exe` at **3,369,984 bytes**. Confirm before copying:
```powershell
(Get-Item Z:\mtconnect-bundles\fanuc\Adapter\FanucCppAdapterWinX64.exe).Length
(Get-FileHash Z:\mtconnect-bundles\fanuc\Adapter\FanucCppAdapterWinX64.exe -Algorithm SHA256).Hash.Substring(0,16)
# expect 3369984 and 6E46DCD51AFFA9E0
```
**1.2** Back up what is on the share now, then copy the three files:
```powershell
$share = '\\tsgwp00525.wjs.geaerospace.net\shared\dt\shopfloor\main\machineapps\mtconnect-bundles\fanuc\Adapter'
Copy-Item $share\FanucCppAdapterWinX64.exe "$share\..\FanucCppAdapterWinX64-3.23.0-replaced-$(Get-Date -f yyyyMMdd).exe"
Copy-Item Z:\mtconnect-bundles\fanuc\Adapter\FanucCppAdapterWinX64.exe $share -Force
Copy-Item Z:\mtconnect-bundles\fanuc\Adapter\Fwlib64.dll $share -Force
Copy-Item Z:\mtconnect-bundles\fanuc\Adapter\fwlibe64.dll $share -Force
```
**1.3** Verify on the share:
```powershell
(Get-FileHash $share\FanucCppAdapterWinX64.exe -Algorithm SHA256).Hash.Substring(0,16) # 6E46DCD51AFFA9E0
```
`adapter.xml` is deliberately NOT copied. The config vocabulary is identical
between 3.23 and 3.25.3, and the share's copy carries site settings.
**Rollback:** copy the `-3.23.0-replaced-` file back over `FanucCppAdapterWinX64.exe`.
---
## Part 2. Fix the two Makino bays
Both have the agent on the machine number and the other three files still on
`MAKINO-1`. The fixed `Update-MachineNumber.ps1` converges them.
| machine | PC when captured | confirm at the bay |
|---|---|---|
| **7502** | `G5W5V7V3ESF` | eDNC reg / enrollment file / plate |
| **7506** | `G4H8KF33ESF` | eDNC reg / enrollment file / plate |
Find the current PC for a machine number in ShopDB rather than trusting the
name above; if the PC was replaced, the new one may never have had the bad
rename applied, in which case check its four files before doing anything.
**2.1** On the bay, elevated PowerShell:
```powershell
. C:\GE Aerospace\lib\Update-MachineNumber.ps1 # or wherever the lib landed
$r = Update-MTConnectVariantName -NewNumber '7502' `
-Root 'C:\Makino-MTConnect' `
-Targets @(
@{ Path='Agent\Devices.xml'; Kind='attr' },
@{ Path='Agent\Devices.EDM.xml'; Kind='attr' },
@{ Path='Data\AdapterDataFile.xml'; Kind='name' },
@{ Path='Agent\Agent.cfg'; Kind='cfg' }
) `
-Services @('Makino Adapter Manager','Makino MTConnect Agent')
$r.Updated; $r.Errors
```
Use the bay's own number - do not paste 7502 onto 7506.
**2.2** Verify all four agree, and nothing still says MAKINO:
```powershell
Select-String -Path C:\Makino-MTConnect\Agent\Devices.xml,`
C:\Makino-MTConnect\Agent\Devices.EDM.xml,`
C:\Makino-MTConnect\Data\AdapterDataFile.xml,`
C:\Makino-MTConnect\Agent\Agent.cfg `
-Pattern 'MAKINO-\d+|7502'
```
**2.3** Confirm it is serving:
```powershell
(Invoke-WebRequest http://localhost:5000/current -UseBasicParsing).Content -match '<MTConnectDevices'
```
**Watch for:** the vendor guide says the Adapter Manager rewrites its whole
configuration from memory when it stops. The function stops it first, but this
has never been proven on real hardware. If the names revert after a reboot,
stop here and tell me - that changes the approach.
---
## Part 3. Fix the seven Fanuc bays
The agent and the adapter disagree. Three have a correct agent and a stale
adapter; four have no agent device name at all and need looking at before any
rename (Part 4).
**Rename these three.** The "correct number" column is what the June map said -
it is a starting point, not authority. Confirm from the bay itself (see the
note at the top) before running anything:
| machine (per map) | adapter says | agent says | PC when captured |
|---|---|---|---|
| **7801** | 4007 | 7801 | `G9N2JNZ3ESF` |
| **7804** | 3031 | 7804 | `GG1J98Y3ESF` |
| **2005 or 2006** | 2005 | 2006 | `GC7ZN7V3ESF` |
`GC7ZN7V3ESF` is the one to be careful with: 2005 and 2006 are a Hwacheon
dual-spindle pair sharing a PC, so two machines legitimately answer to one
hostname and the map and the agent disagree about which. Read the machine plate
and the eDNC registry before running it.
**3.1** On the bay, elevated:
```powershell
. C:\GE Aerospace\lib\Update-MachineNumber.ps1
$r = Update-MTConnectVariantName -NewNumber '7801' `
-Root 'C:\MTConnect' `
-Targets @(
@{ Path='Agent\devices.xml'; Kind='attr' },
@{ Path='Adapter\devices.xml'; Kind='attr' },
@{ Path='Adapter\adapter.xml'; Kind='elem' }
) `
-Services @('MTConnect Adapter Fanuc','MTConnect Agent Fanuc')
$r.Updated; $r.Errors
```
**3.2** Verify the three files agree:
```powershell
Select-String -Path C:\MTConnect\Agent\devices.xml,C:\MTConnect\Adapter\devices.xml `
-Pattern 'name="[^"]+"' | Select-Object -First 2
Select-String -Path C:\MTConnect\Adapter\adapter.xml -Pattern '<Device(Name|ID|UUID)>[^<]+'
```
All should read the same number. Then the HTTP probe as in 2.3 (port 5000).
---
## Part 4. Four Fanuc bays with no agent device name
| machine (per map) | adapter says | PC when captured |
|---|---|---|
| **2013** | 4007 | `GJBJC724ESF` |
| **7606** | 3126 | `G7QLY5X3ESF` |
| **4002** | 4002 | `G6JLMSZ2ESF` |
| **2001** | 3115 | `GB07T5X3ESF` |
These are not a rename problem - the agent's `devices.xml` has no `<Device
name=...>` at all, so the agent has nothing to serve. Do NOT run Part 3 on them.
On each, collect and send back:
```powershell
Get-Service 'MTConnect*' | Select-Object Name,Status,StartType
Get-Content C:\MTConnect\Agent\devices.xml -TotalCount 20
Test-Path C:\MTConnect\Agent\devices.xml
(Invoke-WebRequest http://localhost:5000/current -UseBasicParsing -TimeoutSec 5).StatusCode
Get-Content C:\MTConnect\Agent\agent.log -Tail 30
```
Note three of the four also disagree with the hostname map on the number
(4007 vs 2013, 3126 vs 7606, 3115 vs 2001), so these bays may have been
re-purposed without a full reconfigure.
---
## Part 5. Two questions to settle
**5.1 DMG Mori - do they need a bundle at all?**
Five machines, all flagged `1,2,3` in the machine list, no bundle among the
five we ship, and no captured variant. Their controllers are `DMG Mori F31i
A5/B5` - Fanuc 31i underneath - so the Fanuc adapter may already serve them.
| machine | PC per the machine list |
|---|---|
| WJ-7401 | `G1KDN7X2ESF` |
| WJ-7402 | `G1KCR7X2ESF` |
| WJ-7403 | `G1HYK7X2ESF` |
| WJ-7404 | `G1HGQ7X2ESF` |
| WJ-7405 | `G1KLM7X2ESF` |
Those names come from the `Shop Floor PC Name` column of the OneDrive machine
list and are subject to the same staleness - look the machine up in ShopDB for
the PC that is actually on it today.
On any one of them:
```powershell
Get-Service | Where-Object { $_.Name -match 'MTConnect|Makino|Coordinator' } |
Select-Object Name,Status,StartType
Get-ChildItem C:\ -Directory | Where-Object Name -match 'MTConnect|DMG|Coordinator'
(Invoke-WebRequest http://localhost:5000/current -UseBasicParsing -TimeoutSec 5).StatusCode
```
If they run the Fanuc bundle already, no new variant is needed and the DMG
Coordinator MSI in the OneDrive zip can be ignored. If they collect some other
way, or not at all, that is the answer to whether a sixth bundle is worth
building.
**5.2 Does the live v2 manifest carry the MTConnect entries?**
You are on v2. The concern is that the MTConnect entries were left behind in
the dead v1 manifest, in which case v2 bays are not getting MTConnect from the
manifest engine at all.
```powershell
$m = Get-Content '\\tsgwp00525.wjs.geaerospace.net\shared\dt\shopfloor\main\machineapps\machineapps-manifest.json' -Raw | ConvertFrom-Json
$m.Applications.Count
$m.Applications | ForEach-Object { $_.Name }
```
Expect 8 applications, five of them MTConnect (fanuc, okuma, edncStandard,
edncDualSpindle, makino). Anything less and the missing ones never made it to
v2.
---
## A note on the CSVs
Neither machine-to-PC list will age well: `mtconnect-hostname-machine-map.csv`
(12 June) and the `Shop Floor PC Name` column in `West Jefferson_Machines.csv`
are both hand-built snapshots, and every PC swap silently invalidates a row.
ShopDB now maintains the PC-to-machine link from collector reports, so it
corrects itself as bays are re-imaged. Anything that needs to go from a machine
number to "which PC is on it today" should ask ShopDB; if a CSV is still wanted
for offline use, generate it from ShopDB rather than editing it by hand.
## What I still owe you
- Auto-detection of controller and variant for a new machine. The inputs exist
(`mtconnect-bay-config.csv` has controller IP and FOCAS port per machine, the
machine list has the controller model, the collector already knows the machine
number at imaging time) - design pending.
- The code fix is committed and pushed as `0d9c4fd` on `pxe-webapp-redesign`.
It changes future renames only; it does not reach out and fix these bays.

View File

@@ -0,0 +1,380 @@
<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<!-- 1. windowsPE is intentionally empty -->
<settings pass="windowsPE" />
<!-- 2. Offline servicing (drivers) -->
<settings pass="offlineServicing">
<component name="Microsoft-Windows-PnpCustomizationsNonWinPE"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS">
<DriverPaths>
<PathAndCredentials wcm:action="add" wcm:keyValue="1">
<Path>W:\Drivers</Path>
</PathAndCredentials>
<PathAndCredentials wcm:action="add" wcm:keyValue="2">
<Path>W:\Deploy\Applications\extra\printdrivers\BROTHER\UNIV-PS-01181\PS\64</Path>
</PathAndCredentials>
<PathAndCredentials wcm:action="add" wcm:keyValue="3">
<Path>W:\Deploy\Applications\extra\printdrivers\HP</Path>
</PathAndCredentials>
<PathAndCredentials wcm:action="add" wcm:keyValue="4">
<Path>W:\Deploy\Applications\extra\printdrivers\XEROX\UNIV_5.1035.2.0_PS_x64_Driver</Path>
</PathAndCredentials>
</DriverPaths>
</component>
</settings>
<!-- 3. specialize: computer naming + RunSynchronous all in ONE component -->
<settings pass="specialize">
<!-- 3a. Shell-Setup for naming/owner/org -->
<component name="Microsoft-Windows-Shell-Setup"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS">
<ComputerName>H%serialnumber%</ComputerName>
<RegisteredOrganization>GE Aerospace</RegisteredOrganization>
<RegisteredOwner>GE</RegisteredOwner>
<TimeZone>Eastern Standard Time</TimeZone>
</component>
<!-- 3b. RunSynchronous for all of your installers, copies, etc. -->
<component name="Microsoft-Windows-Deployment"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS">
<RunSynchronous>
<!-- INTERNETACCESS bootstrap profile. BLUESSO and EAP-PEAP were removed:
the MCL ppkg installs both, and BLUESSO cannot bootstrap (hidden
SSID, manual connect, user password). wlansvc must be started
first or netsh silently fails. -->
<RunSynchronousCommand wcm:action="add">
<Order>1</Order>
<Path>cmd /c sc config wlansvc start= auto &amp; net start wlansvc &amp; exit /b 0</Path>
<Description>Start WLAN AutoConfig so the WiFi profile can be added</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>3</Order>
<Path>cmd /c netsh wlan add profile filename="C:\Deploy\Applications\extra\wireless\WiFi-Profile.xml" user=all</Path>
<Description>Add generic WiFi profile</Description>
</RunSynchronousCommand>
<!-- Certificates -->
<RunSynchronousCommand wcm:action="add">
<Order>4</Order>
<Path>cmd /c certutil -addstore Root "C:\Deploy\Applications\GE_External_Root_CA_2_1.cer"</Path>
<Description>Install External Root Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>5</Order>
<Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_External_Intermediate_CA_2_1.cer"</Path>
<Description>Install External Intermediate Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>6</Order>
<Path>cmd /c certutil -addstore Root "C:\Deploy\Applications\GE_Enterprise_Root_CA_2_1.cer"</Path>
<Description>Install Enterprise Root Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>7</Order>
<Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_Enterprise_Device_Issuing_CA_2_1.cer"</Path>
<Description>Install Enterprise Device Issuing Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>8</Order>
<Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_Enterprise_Server_Issuing_CA_2_1.cer"</Path>
<Description>Install Enterprise Server Issuing Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>9</Order>
<Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_Enterprise_Smart_Card_Issuing_CA_2_1.cer"</Path>
<Description>Install SmartCard Issuing Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>10</Order>
<Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_Enterprise_User_Issuing_CA_2_1.cer"</Path>
<Description>Install User Issuing Certificate</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>11</Order>
<Path>cmd /c certutil -addstore Root "C:\Deploy\Applications\GE_Aerospace_Enterprise_Root_CA_1.cer"</Path>
<Description>Install Aerospace Enterprise Root CA</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>12</Order>
<Path>cmd /c certutil -addstore Root "C:\Deploy\Applications\ZscalerCommercialCertificate-2048-SHA256.crt"</Path>
<Description>Install Zscaler Certificate</Description>
</RunSynchronousCommand>
<!-- Fonts -->
<RunSynchronousCommand wcm:action="add">
<Order>13</Order>
<Path>cmd /c copy "C:\Deploy\Applications\extra\fonts\3OF9.TTF" "%WINDIR%\Fonts\" /Y</Path>
<Description>Copy 3OF9 Font</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>14</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts" /v "3 of 9 Barcode" /t REG_SZ /d "3OF9.TTF" /f</Path>
<Description>Register 3OF9 Barcode Font</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>15</Order>
<Path>cmd /c copy "C:\Deploy\Applications\extra\fonts\Code39AzaleaNarrow3.ttf" "%WINDIR%\Fonts\" /Y</Path>
<Description>Copy Code39 Azalea Narrow Font</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>16</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts" /v "Code39AzaleaNarrow3" /t REG_SZ /d "Code39AzaleaNarrow3.ttf" /f</Path>
<Description>Register Code39 Azalea Narrow Font</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>17</Order>
<Path>cmd /c copy "C:\Deploy\Applications\extra\fonts\Code39Azalea.ttf" "%WINDIR%\Fonts\" /Y</Path>
<Description>Copy Code39 Azalea Font</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>18</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts" /v "Code39Azalea" /t REG_SZ /d "Code39Azalea.ttf" /f</Path>
<Description>Register Code39 Azalea Font</Description>
</RunSynchronousCommand>
<!-- OpenText installers -->
<RunSynchronousCommand wcm:action="add">
<Order>19</Order>
<Path>cmd /c "C:\Deploy\Applications\extra\opentext\Setup-OpenText.cmd"</Path>
<Description>Install OpenText HostExplorer SP1 (shopfloor method)</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>20</Order>
<Path>C:\Deploy\Applications\extra\opentext\J2SE_Runtime_Environment_1.6.0_22_Static_Config_V2_Co-Exist.EXE /silent /norestart</Path>
<Description>Install J2SE Runtime Environment 1.6.0_22</Description>
</RunSynchronousCommand>
<!-- Adobe -->
<RunSynchronousCommand wcm:action="add">
<Order>22</Order>
<Path>msiexec /i "C:\Deploy\Applications\extra\adobe\AcroRead.msi" TRANSFORMS="C:\Deploy\Applications\extra\adobe\AcroRead.mst" /quiet /norestart</Path>
<Description>Install Adobe</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>23</Order>
<Path>msiexec /p "C:\Deploy\Applications\extra\adobe\AcroRdrDCUpd2500120531.msp" /quiet /norestart</Path>
<Description>Apply Adobe Reader Update</Description>
</RunSynchronousCommand>
<!-- Java JRE 8 Enterprise Installation -->
<RunSynchronousCommand wcm:action="add">
<Order>26</Order>
<Path>C:\Deploy\Applications\extra\java\jre-8u441-windows-i586.exe /s INSTALLDIR="C:\Program Files (x86)\Java\jre1.8.0_441" STATIC=1 AUTO_UPDATE=0 REBOOT=0 SPONSORS=0 WEB_JAVA=0 /L "C:\Windows\Logs\java-install.log"</Path>
<Description>Install Java JRE 8 Update 441 with Enterprise Settings and Logging</Description>
</RunSynchronousCommand>
<!-- Disable Java Auto Updates via Registry (64-bit) -->
<RunSynchronousCommand wcm:action="add">
<Order>27</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\JavaSoft\Java Update\Policy" /v EnableJavaUpdate /t REG_DWORD /d 0 /f</Path>
<Description>Disable Java Auto Updates (64-bit)</Description>
</RunSynchronousCommand>
<!-- Disable Java Auto Updates via Registry (32-bit) -->
<RunSynchronousCommand wcm:action="add">
<Order>28</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Wow6432Node\JavaSoft\Java Update\Policy" /v EnableJavaUpdate /t REG_DWORD /d 0 /f</Path>
<Description>Disable Java Auto Updates (32-bit)</Description>
</RunSynchronousCommand>
<!-- Disable Java Auto Update Check (64-bit) -->
<RunSynchronousCommand wcm:action="add">
<Order>29</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\JavaSoft\Java Update\Policy" /v EnableAutoUpdateCheck /t REG_DWORD /d 0 /f</Path>
<Description>Disable Java Auto Update Check (64-bit)</Description>
</RunSynchronousCommand>
<!-- Disable Java Auto Update Check (32-bit) -->
<RunSynchronousCommand wcm:action="add">
<Order>30</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Wow6432Node\JavaSoft\Java Update\Policy" /v EnableAutoUpdateCheck /t REG_DWORD /d 0 /f</Path>
<Description>Disable Java Auto Update Check (32-bit)</Description>
</RunSynchronousCommand>
<!-- Disable Cortana -->
<RunSynchronousCommand wcm:action="add">
<Order>31</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Windows Search" /v AllowCortana /t REG_DWORD /d 0 /f</Path>
<Description>Disable Cortana</Description>
</RunSynchronousCommand>
<!-- Disable Web Search in Start Menu -->
<RunSynchronousCommand wcm:action="add">
<Order>33</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Windows Search" /v DisableWebSearch /t REG_DWORD /d 1 /f</Path>
<Description>Disable Web Search in Start Menu</Description>
</RunSynchronousCommand>
<!-- Disable Bing / web search suggestions in Start menu.
BingSearchEnabled is documented at HKCU\Software\Microsoft\Windows\CurrentVersion\Search
(not HKLM), so the old HKLM policy placement was a no-op. The current documented
suppression is DisableSearchBoxSuggestions=1 at Software\Policies\Microsoft\Windows\Explorer.
Writing it into the Default User hive so every newly-created account inherits it. -->
<RunSynchronousCommand wcm:action="add">
<Order>34</Order>
<Path>cmd /c reg load HKU\TempDU C:\Users\Default\NTUSER.DAT &amp; reg add "HKU\TempDU\Software\Policies\Microsoft\Windows\Explorer" /v DisableSearchBoxSuggestions /t REG_DWORD /d 1 /f &amp; reg unload HKU\TempDU</Path>
<Description>Disable Bing/web search suggestions (Default User hive)</Description>
</RunSynchronousCommand>
<!-- Disable Search Web when searching Windows -->
<RunSynchronousCommand wcm:action="add">
<Order>35</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Windows Search" /v ConnectedSearchUseWeb /t REG_DWORD /d 0 /f</Path>
<Description>Disable Connected Search Use Web</Description>
</RunSynchronousCommand>
<!-- Disable Cortana Speech Recognition -->
<RunSynchronousCommand wcm:action="add">
<Order>36</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\InputPersonalization" /v AllowInputPersonalization /t REG_DWORD /d 0 /f</Path>
<Description>Disable Cortana Speech Recognition</Description>
</RunSynchronousCommand>
<!-- Disable Search Highlights (news/trending) -->
<RunSynchronousCommand wcm:action="add">
<Order>38</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Windows Search" /v EnableDynamicContentInWSB /t REG_DWORD /d 0 /f</Path>
<Description>Disable Search Highlights</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>39</Order>
<Path>powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\Deploy\Applications\BPRT\Register-BulkEnrollTask-Standard.ps1"</Path>
<Description>Add bulk enrollment orchestrator to Run key</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>40</Order>
<Path>reg.exe load HKU\TempDefault C:\Users\Default\NTUSER.DAT</Path>
<Description>Load Default User registry hive</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>41</Order>
<Path>reg.exe add "HKU\TempDefault\Software\Microsoft\Windows\CurrentVersion\Explorer\Serialize" /v StartupDelayInMSec /t REG_DWORD /d 0 /f</Path>
<Description>Disable startup delay for all new users</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>42</Order>
<Path>reg.exe add "HKU\TempDefault\Software\Microsoft\Windows\CurrentVersion\Explorer\Serialize" /v WaitForIdleState /t REG_DWORD /d 0 /f</Path>
<Description>Disable wait for idle state for all new users</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>43</Order>
<Path>reg.exe add "HKU\TempDefault\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced" /v StartShownOnUpgrade /t REG_DWORD /d 1 /f</Path>
<Description>Prevent Start menu from opening on first login</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>44</Order>
<Path>reg.exe unload HKU\TempDefault</Path>
<Description>Unload Default User registry hive</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>45</Order>
<Path>reg add HKLM\Software\Policies\Microsoft\Windows\WindowsUpdate\AU /v NoAutoUpdate /t REG_DWORD /d 1 /f</Path>
<Description>Disable automatic Windows updates (NoAutoUpdate)</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>46</Order>
<Path>reg add HKLM\Software\Policies\Microsoft\Windows\WindowsUpdate\AU /v AUOptions /t REG_DWORD /d 1 /f</Path>
<Description>Disable automatic Windows updates (AUOptions)</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>47</Order>
<Path>reg.exe add "HKLM\Software\Microsoft\Command Processor" /v AutoRun /t REG_SZ /d "doskey vanilla=powershell.exe -ExecutionPolicy Bypass -WindowStyle Hidden -File C:\Deploy\Applications\BPRT\Show-VanillaConfirmation.ps1" /f</Path>
<Description>Define vanilla CMD alias for manual OOBE fallback via Shift+F10</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>48</Order>
<Path>reg.exe add "HKLM\SOFTWARE\Policies\Microsoft\Windows\RemovableStorageDevices" /v Deny_All /t REG_DWORD /d 1 /f</Path>
<Description>Block all removable media reads to prevent PPKG auto-detection at OOBE</Description>
</RunSynchronousCommand>
</RunSynchronous>
</component>
</settings>
<!-- 4. oobeSystem: hide OEM/EULA screens -->
<settings pass="oobeSystem">
<component name="Microsoft-Windows-Shell-Setup"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS">
<OOBE>
<HideEULAPage>true</HideEULAPage>
<HideOEMRegistrationScreen>true</HideOEMRegistrationScreen>
<HideOnlineAccountScreens>false</HideOnlineAccountScreens>
<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
<HideLocalAccountScreen>true</HideLocalAccountScreen>
<NetworkLocation>Work</NetworkLocation>
<ProtectYourPC>3</ProtectYourPC>
<SkipUserOOBE>false</SkipUserOOBE>
<SkipMachineOOBE>false</SkipMachineOOBE>
</OOBE>
<FirstLogonCommands>
<SynchronousCommand wcm:action="add">
<CommandLine>cmd /c sc config wuauserv start= disabled</CommandLine>
<Description>disable windows update</Description>
<Order>4</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>cmd /c sc stop wuauserv</CommandLine>
<Description>disable windows update</Description>
<Order>5</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>reg.exe load HKU\TempDefault C:\Users\Default\NTUSER.DAT</CommandLine>
<Description>Load Default User registry hive to reverse startup delay</Description>
<Order>6</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>reg.exe delete "HKU\TempDefault\Software\Microsoft\Windows\CurrentVersion\Explorer\Serialize" /v StartupDelayInMSec /f</CommandLine>
<Description>Remove startup delay override from Default User</Description>
<Order>7</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>reg.exe delete "HKU\TempDefault\Software\Microsoft\Windows\CurrentVersion\Explorer\Serialize" /v WaitForIdleState /f</CommandLine>
<Description>Remove wait for idle state override from Default User</Description>
<Order>8</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>reg.exe unload HKU\TempDefault</CommandLine>
<Description>Unload Default User registry hive</Description>
<Order>9</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>reg.exe delete "HKLM\SOFTWARE\Policies\Microsoft\Windows\RemovableStorageDevices" /v Deny_All /f</CommandLine>
<Description>Restore removable media access after OOBE PPKG block</Description>
<Order>10</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>11</Order>
<CommandLine>powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\Deploy\Applications\extra_engineering\Install-EngineeringLayer.ps1"</CommandLine>
<!-- Copies the 653 MB payload out of C:\Deploy to C:\PreInstall\engineering
BEFORE installing: the bulk-enrollment orchestrator registers a
DeleteDeployDir task that removes C:\Deploy on the next boot, and this
installer (.NET 3.5, VC++, Cygwin, DUU) can reboot part way through.
Idempotent, marker-gated. Description is capped at 256 chars, so the
detail lives here. -->
<Description>Engineering Layer: stage payload out of C:\Deploy then install</Description>
</SynchronousCommand>
</FirstLogonCommands>
</component>
</settings>
</unattend>

View File

@@ -97,6 +97,52 @@
<Path>reg.exe add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\OOBE" /v SkipUserOOBE /t REG_DWORD /d 1 /f</Path>
<Description>Skip user OOBE phase</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>17</Order>
<Path>reg.exe load HKU\TempDefault C:\Users\Default\NTUSER.DAT</Path>
<Description>Load Default User registry hive</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>18</Order>
<Path>reg.exe add "HKU\TempDefault\Software\Microsoft\Windows\CurrentVersion\Explorer\Serialize" /v StartupDelayInMSec /t REG_DWORD /d 0 /f</Path>
<Description>Disable startup delay for all new users</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>19</Order>
<Path>reg.exe add "HKU\TempDefault\Software\Microsoft\Windows\CurrentVersion\Explorer\Serialize" /v WaitForIdleState /t REG_DWORD /d 0 /f</Path>
<Description>Disable wait for idle state for all new users</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>20</Order>
<Path>reg.exe add "HKU\TempDefault\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced" /v StartShownOnUpgrade /t REG_DWORD /d 1 /f</Path>
<Description>Prevent Start menu from opening on first login</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>21</Order>
<Path>reg.exe unload HKU\TempDefault</Path>
<Description>Unload Default User registry hive</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>22</Order>
<Path>reg add HKLM\Software\Policies\Microsoft\Windows\WindowsUpdate\AU /v NoAutoUpdate /t REG_DWORD /d 1 /f</Path>
<Description>Disable automatic Windows updates (NoAutoUpdate)</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>23</Order>
<Path>reg add HKLM\Software\Policies\Microsoft\Windows\WindowsUpdate\AU /v AUOptions /t REG_DWORD /d 1 /f</Path>
<Description>Disable automatic Windows updates (AUOptions)</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>24</Order>
<Path>reg.exe add "HKLM\Software\Microsoft\Command Processor" /v AutoRun /t REG_SZ /d "doskey vanilla=powershell.exe -ExecutionPolicy Bypass -WindowStyle Hidden -File C:\Deploy\Applications\BPRT\Show-VanillaConfirmation.ps1" /f</Path>
<Description>Define vanilla CMD alias for manual OOBE fallback via Shift+F10</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>25</Order>
<Path>reg.exe add "HKLM\SOFTWARE\Policies\Microsoft\Windows\RemovableStorageDevices" /v Deny_All /t REG_DWORD /d 1 /f</Path>
<Description>Block all removable media reads to prevent PPKG auto-detection at OOBE</Description>
</RunSynchronousCommand>
</RunSynchronous>
</component>
</settings>
@@ -136,7 +182,7 @@
</Password>
<Enabled>true</Enabled>
<Username>SupportUser</Username>
<LogonCount>7</LogonCount>
<LogonCount>12</LogonCount>
</AutoLogon>
<FirstLogonCommands>
<SynchronousCommand wcm:action="add">
@@ -156,39 +202,85 @@
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>4</Order>
<CommandLine>powershell.exe -ep Bypass -Command "net use Z: \\172.16.9.1\enrollment /user:pxe-upload pxe; robocopy Z:\shopfloor-setup C:\Enrollment Fetch-StagingPayload.ps1 Verify-And-Heal-Staging.ps1 /R:2 /W:2; robocopy Z:\scripts C:\Enrollment run-enrollment.ps1 wait-for-internet.ps1 migrate-to-wifi.ps1 /R:2 /W:2"</CommandLine>
<Description>Bootstrap self-heal: pull Fetch/Verify-Heal + enrollment scripts from the PXE share so Order 5/6 run after an early WinPE staging failure.</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>5</Order>
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\Fetch-StagingPayload.ps1"</CommandLine>
<Description>Fetch bulk staging (shopfloor-setup tree + preinstall bundle) from the PXE share on a fresh mount, BEFORE the production-network switch takes the bay off the imaging LAN. Detailed log at C:\Logs\Fetch\.</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>5</Order>
<Order>6</Order>
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\Verify-And-Heal-Staging.ps1"</CommandLine>
<Description>Verify every imaging payload arrived and re-pull anything missing from the PXE share (incl the CMM bundle + selected-bay backup) while still on the imaging LAN, BEFORE wait-for-internet switches the bay to the production network. Log at C:\Logs\Fetch\.</Description>
<Description>Verify + re-pull any missing imaging payload from the PXE share (CMM/Keyence/WaxTrace bundles + bay backup) on the imaging LAN before the production-network switch. Log C:\Logs\Fetch.</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>6</Order>
<Order>7</Order>
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\wait-for-internet.ps1"</CommandLine>
<Description>Prompt to connect production network then wait for TCP 443 connectivity</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>7</Order>
<Order>8</Order>
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\migrate-to-wifi.ps1"</CommandLine>
<Description>Migrate from wired to WiFi if WiFi adapter present, else stay on wired</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>8</Order>
<Order>9</Order>
<CommandLine>msiexec.exe /i "C:\PreInstall\installers\powershell7\PowerShell-7.5.4-win-x64.msi" /qn /norestart ADD_PATH=1 USE_MU=0 ENABLE_MU=0 DISABLE_TELEMETRY=1</CommandLine>
<Description>Install PowerShell 7 BEFORE PPKG so Intune SetupCredentials Win32App finds pwsh.exe (race fix)</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>9</Order>
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\run-enrollment.ps1"</CommandLine>
<Order>10</Order>
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\run-enrollment.ps1"</CommandLine>
<!-- run-enrollment.ps1 lives in C:\Enrollment: startnet stages it and
the .ppkg there. The earlier C:\run-enrollment.ps1 path pointed at a
file that never existed, so this step silently did nothing.
Keep prose in comments - Description is capped at 256 chars and an
over-length one invalidates the whole answer file for its pass. -->
<Description>Run GCCH Enrollment</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>10</Order>
<Order>11</Order>
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\Run-ShopfloorSetup.ps1"</CommandLine>
<Description>Run shopfloor PC type setup</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>cmd /c sc config wuauserv start= disabled</CommandLine>
<Description>disable windows update</Description>
<Order>12</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>cmd /c sc stop wuauserv</CommandLine>
<Description>disable windows update</Description>
<Order>13</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>reg.exe load HKU\TempDefault C:\Users\Default\NTUSER.DAT</CommandLine>
<Description>Load Default User registry hive to reverse startup delay</Description>
<Order>14</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>reg.exe delete "HKU\TempDefault\Software\Microsoft\Windows\CurrentVersion\Explorer\Serialize" /v StartupDelayInMSec /f</CommandLine>
<Description>Remove startup delay override from Default User</Description>
<Order>15</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>reg.exe delete "HKU\TempDefault\Software\Microsoft\Windows\CurrentVersion\Explorer\Serialize" /v WaitForIdleState /f</CommandLine>
<Description>Remove wait for idle state override from Default User</Description>
<Order>16</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>reg.exe unload HKU\TempDefault</CommandLine>
<Description>Unload Default User registry hive</Description>
<Order>17</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>reg.exe delete "HKLM\SOFTWARE\Policies\Microsoft\Windows\RemovableStorageDevices" /v Deny_All /f</CommandLine>
<Description>Restore removable media access after OOBE PPKG block</Description>
<Order>18</Order>
</SynchronousCommand>
</FirstLogonCommands>
<TimeZone>Eastern Standard Time</TimeZone>
</component>

View File

@@ -51,18 +51,15 @@
language="neutral"
versionScope="nonSxS">
<RunSynchronous>
<!-- EAP-PEAP MSI -->
<RunSynchronousCommand wcm:action="add">
<Order>1</Order>
<Path>msiexec /i "C:\Deploy\Applications\extra\wireless\EAP-PEAP.msi" /quiet /norestart</Path>
<Description>Install EAP-PEAP</Description>
</RunSynchronousCommand>
<!-- Wi-Fi profiles -->
<!-- INTERNETACCESS bootstrap profile. BLUESSO and EAP-PEAP were removed:
the MCL ppkg installs both, and BLUESSO cannot bootstrap (hidden
SSID, manual connect, user password). wlansvc must be started
first or netsh silently fails. -->
<RunSynchronousCommand wcm:action="add">
<Order>2</Order>
<Path>cmd /c netsh wlan add profile filename="C:\Deploy\Applications\extra\wireless\BLUESSO.xml" user=all</Path>
<Description>Add BLUESSO WiFi profile</Description>
<Order>1</Order>
<Path>cmd /c sc config wlansvc start= auto &amp; net start wlansvc &amp; exit /b 0</Path>
<Description>Start WLAN AutoConfig so the WiFi profile can be added</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>3</Order>
@@ -152,19 +149,14 @@
<!-- OpenText installers -->
<RunSynchronousCommand wcm:action="add">
<Order>19</Order>
<Path>C:\Deploy\Applications\extra\opentext\opentext_hostexplorer_sp1_15.0_v01.exe /quiet /norestart</Path>
<Description>Install OpenText HostExplorer SP1</Description>
<Path>cmd /c "C:\Deploy\Applications\extra\opentext\Setup-OpenText.cmd"</Path>
<Description>Install OpenText HostExplorer SP1 (shopfloor method)</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>20</Order>
<Path>C:\Deploy\Applications\extra\opentext\J2SE_Runtime_Environment_1.6.0_22_Static_Config_V2_Co-Exist.EXE /silent /norestart</Path>
<Description>Install J2SE Runtime Environment 1.6.0_22</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>21</Order>
<Path>C:\Deploy\Applications\extra\opentext\unattended.bat</Path>
<Description>Install J2SE Runtime Environment 1.6.0_22</Description>
</RunSynchronousCommand>
<!-- Adobe -->
<RunSynchronousCommand wcm:action="add">
@@ -258,7 +250,58 @@
<Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Windows Search" /v EnableDynamicContentInWSB /t REG_DWORD /d 0 /f</Path>
<Description>Disable Search Highlights</Description>
</RunSynchronousCommand>
</RunSynchronous>
<RunSynchronousCommand wcm:action="add">
<Order>39</Order>
<Path>powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\Deploy\Applications\BPRT\Register-BulkEnrollTask-Standard.ps1"</Path>
<Description>Add bulk enrollment orchestrator to Run key</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>40</Order>
<Path>reg.exe load HKU\TempDefault C:\Users\Default\NTUSER.DAT</Path>
<Description>Load Default User registry hive</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>41</Order>
<Path>reg.exe add "HKU\TempDefault\Software\Microsoft\Windows\CurrentVersion\Explorer\Serialize" /v StartupDelayInMSec /t REG_DWORD /d 0 /f</Path>
<Description>Disable startup delay for all new users</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>42</Order>
<Path>reg.exe add "HKU\TempDefault\Software\Microsoft\Windows\CurrentVersion\Explorer\Serialize" /v WaitForIdleState /t REG_DWORD /d 0 /f</Path>
<Description>Disable wait for idle state for all new users</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>43</Order>
<Path>reg.exe add "HKU\TempDefault\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced" /v StartShownOnUpgrade /t REG_DWORD /d 1 /f</Path>
<Description>Prevent Start menu from opening on first login</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>44</Order>
<Path>reg.exe unload HKU\TempDefault</Path>
<Description>Unload Default User registry hive</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>45</Order>
<Path>reg add HKLM\Software\Policies\Microsoft\Windows\WindowsUpdate\AU /v NoAutoUpdate /t REG_DWORD /d 1 /f</Path>
<Description>Disable automatic Windows updates (NoAutoUpdate)</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>46</Order>
<Path>reg add HKLM\Software\Policies\Microsoft\Windows\WindowsUpdate\AU /v AUOptions /t REG_DWORD /d 1 /f</Path>
<Description>Disable automatic Windows updates (AUOptions)</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>47</Order>
<Path>reg.exe add "HKLM\Software\Microsoft\Command Processor" /v AutoRun /t REG_SZ /d "doskey vanilla=powershell.exe -ExecutionPolicy Bypass -WindowStyle Hidden -File C:\Deploy\Applications\BPRT\Show-VanillaConfirmation.ps1" /f</Path>
<Description>Define vanilla CMD alias for manual OOBE fallback via Shift+F10</Description>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>48</Order>
<Path>reg.exe add "HKLM\SOFTWARE\Policies\Microsoft\Windows\RemovableStorageDevices" /v Deny_All /t REG_DWORD /d 1 /f</Path>
<Description>Block all removable media reads to prevent PPKG auto-detection at OOBE</Description>
</RunSynchronousCommand>
</RunSynchronous>
</component>
</settings>
@@ -273,7 +316,7 @@
<HideEULAPage>true</HideEULAPage>
<HideOEMRegistrationScreen>true</HideOEMRegistrationScreen>
<HideOnlineAccountScreens>false</HideOnlineAccountScreens>
<HideWirelessSetupInOOBE>false</HideWirelessSetupInOOBE>
<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
<HideLocalAccountScreen>true</HideLocalAccountScreen>
<NetworkLocation>Work</NetworkLocation>
<ProtectYourPC>3</ProtectYourPC>
@@ -281,26 +324,45 @@
<SkipMachineOOBE>false</SkipMachineOOBE>
</OOBE>
<FirstLogonCommands>
<SynchronousCommand wcm:action="add">
<Order>1</Order>
<CommandLine>shutdown -a</CommandLine>
<Description>Cancel any scheduled shutdown from Office installation</Description>
</SynchronousCommand>
<!-- Install Zscaler -->
<SynchronousCommand wcm:action="add">
<Order>2</Order>
<CommandLine>C:\Deploy\Applications\extra\zscaler\zscaler.bat</CommandLine>
<Description>Install Zscaler Client Connector</Description>
</SynchronousCommand>
<!-- Install Microsoft Office -->
<SynchronousCommand wcm:action="add">
<Order>3</Order>
<CommandLine>cmd /c "cd /d C:\Deploy\Applications\extra\office\ &amp;&amp; install.bat"</CommandLine>
<Description>Install Microsoft Office</Description>
</SynchronousCommand>
</FirstLogonCommands>
<SynchronousCommand wcm:action="add">
<CommandLine>cmd /c sc config wuauserv start= disabled</CommandLine>
<Description>disable windows update</Description>
<Order>4</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>cmd /c sc stop wuauserv</CommandLine>
<Description>disable windows update</Description>
<Order>5</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>reg.exe load HKU\TempDefault C:\Users\Default\NTUSER.DAT</CommandLine>
<Description>Load Default User registry hive to reverse startup delay</Description>
<Order>6</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>reg.exe delete "HKU\TempDefault\Software\Microsoft\Windows\CurrentVersion\Explorer\Serialize" /v StartupDelayInMSec /f</CommandLine>
<Description>Remove startup delay override from Default User</Description>
<Order>7</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>reg.exe delete "HKU\TempDefault\Software\Microsoft\Windows\CurrentVersion\Explorer\Serialize" /v WaitForIdleState /f</CommandLine>
<Description>Remove wait for idle state override from Default User</Description>
<Order>8</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>reg.exe unload HKU\TempDefault</CommandLine>
<Description>Unload Default User registry hive</Description>
<Order>9</Order>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<CommandLine>reg.exe delete "HKLM\SOFTWARE\Policies\Microsoft\Windows\RemovableStorageDevices" /v Deny_All /f</CommandLine>
<Description>Restore removable media access after OOBE PPKG block</Description>
<Order>10</Order>
</SynchronousCommand>
</FirstLogonCommands>
</component>
</settings>

View File

@@ -34,7 +34,7 @@
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
"DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{9BE518E6-ECC6-35A9-88E4-87755C07200F}",
"PCTypes": ["*"]
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
},
{
"_comment": "VC++ 2010 x86 - same fix as 2008. Bootstrapper ignores /norestart; extracted MSI with REBOOT=ReallySuppress does not.",
@@ -44,7 +44,7 @@
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
"DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{F0C3E5D1-1ADE-321E-8167-68EF0DE699A5}",
"PCTypes": ["*"]
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
},
{
"_comment": "VC++ 2012 x86 Minimum Runtime - extracted from vcredist2012_x86.exe Burn bundle. Same REBOOT=ReallySuppress fix.",
@@ -54,7 +54,7 @@
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
"DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{BD95A8CD-1D9F-35AD-981A-3E7925026EBB}",
"PCTypes": ["*"]
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
},
{
"Name": "VC++ Redistributable 2012 x86 (Additional)",
@@ -63,7 +63,7 @@
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
"DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{B175520C-86A2-35A7-8619-86DC379688B9}",
"PCTypes": ["*"]
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
},
{
"_comment": "VC++ 2013 x86 Minimum Runtime - extracted from vcredist2013_x86.exe Burn bundle.",
@@ -73,7 +73,7 @@
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
"DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{13A4EE12-23EA-3371-91EE-EFB36DDFFF3E}",
"PCTypes": ["*"]
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
},
{
"Name": "VC++ Redistributable 2013 x86 (Additional)",
@@ -82,7 +82,7 @@
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
"DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{F8CFEB22-A2E7-3971-9EDA-4B11EDEFC185}",
"PCTypes": ["*"]
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
},
{
"_comment": "VC++ 2013 x64 Minimum Runtime - required by Keyence VR-6000 Series Software. Extracted from the Keyence installer's Windows Installer cache. Same REBOOT=ReallySuppress pattern as the x86 variants.",
@@ -92,7 +92,7 @@
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
"DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{A749D8E6-B613-3BE3-8F5F-045C84EBA29B}",
"PCTypes": ["*"]
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
},
{
"_comment": "VC++ 2013 x64 Additional Runtime - required by Keyence VR-6000 Series Software. Pairs with the Minimum Runtime above.",
@@ -102,7 +102,7 @@
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
"DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{929FBD26-9020-399B-9A7A-751D61F0B942}",
"PCTypes": ["*"]
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
},
{
"_comment": "VC++ 2010 x64 - required by PC-DMIS 2016/2019 R2 on CMM PCs. PCDLRN.exe links against msvcr100.dll and the VS 2010 MFC DLLs which are only provided by this redistributable. Extracted from the PC-DMIS 2016 bundle's attached container (a1 payload). Silent install: /q /norestart. Detection: Uninstall key under the native x64 hive with fixed product GUID.",
@@ -114,7 +114,7 @@
"DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{1D8E6291-B0D5-35EC-8441-6616F567A0F7}",
"DetectionName": "DisplayVersion",
"DetectionValue": "10.0.40219",
"PCTypes": ["*"]
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
},
{
"_comment": "VC++ 2012 x64 - required by PC-DMIS 2016/2019 R2. Same rationale as 2010 x64; PC-DMIS links against msvcr110.dll / MFC110. Extracted from the PC-DMIS 2016 bundle's attached container (a2 payload). Detection on the Minimum Runtime GUID (the main redist wrapper installs both Minimum and Additional sub-packages).",
@@ -126,7 +126,7 @@
"DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{5AF4E09F-5C9B-3AAF-B731-544D3DC821DD}",
"DetectionName": "DisplayVersion",
"DetectionValue": "11.0.51106",
"PCTypes": ["*"]
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
},
{
"_comment": "VC++ 2015-2022 x86 - extracted from vcredist2015_2017_2019_2022_x86.exe Burn bundle. The bundle contains 2022 14.44.35211 plus 8 chained KB updates for older 2015/2017/2019 releases. We install only the 2022 Min+Add MSIs - the CRT v140 ABI is shared across 2015/2017/2019/2022, so the latest pair covers all four versions on Windows 10/11.",
@@ -136,7 +136,7 @@
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
"DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{922480B5-CAEB-4B1B-AAA4-9716EFDCE26B}",
"PCTypes": ["*"]
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
},
{
"Name": "VC++ Redistributable 2022 x86 (Additional)",
@@ -145,6 +145,16 @@
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
"DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{C18FB403-1E88-43C8-AD8A-CED50F23DE8B}",
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
},
{
"_comment": "VC++ 2015-2022 x64 - required by PC-DMIS 2026 (PCDLRN.exe links vcruntime140_1.dll, added in VC++ 2019 / v14.20). The x64 redist line above previously stopped at 2013, so 64-bit PC-DMIS 2026 was missing its runtime and PCDLRN.exe failed to start with 'vcruntime140_1.dll was not found'. The patched PC-DMIS MSI bypasses Hexagon's Burn bundle, which is what would otherwise have pulled this in. Full vc_redist.x64.exe (14.44.35211, 2015-2022 shared CRT). Modern bootstrapper honors /norestart. Detect on the DLL itself so it is version-independent.",
"Name": "VC++ Redistributable 2015-2022 x64",
"Installer": "vcredist/2022-x64/vc_redist.x64.exe",
"Type": "EXE",
"InstallArgs": "/install /quiet /norestart",
"DetectionMethod": "File",
"DetectionPath": "C:\\Windows\\System32\\vcruntime140_1.dll",
"PCTypes": ["*"]
},
{
@@ -155,7 +165,7 @@
"InstallArgs": "",
"DetectionMethod": "File",
"DetectionPath": "C:\\Program Files (x86)\\Adobe\\Acrobat Reader DC\\Reader\\AcroRd32.exe",
"PCTypes": ["*"]
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
},
{
"_comment": "OpenText HostExplorer 15 SP1 ShopFloor - replaced the WJDT-built OpenText.exe Inno wrapper with our own Setup-OpenText.ps1 that does the same install steps (msiexec base + ShopFloor.mst transform + SP1 .msp patch) PLUS fans the per-user profile/keymap/menu/macro content out to Default User and every existing user profile. The Inno wrapper deployed per-user content to {userappdata} which resolves to whichever user is running the installer (SYSTEM under DSC, single user under PreInstall) so Azure-AD users never saw the profiles. Setup-OpenText.cmd is a tiny launcher that hands off to Setup-OpenText.ps1 because the runner only handles MSI/EXE types. All bundled files live in vcredist-style subtree at dependencies/opentext/ and get xcopied through WinPE staging. NO DetectionMethod here on purpose - Setup-OpenText.ps1 owns its own version check by reading version.txt next to itself and comparing to HKLM:\\SOFTWARE\\GE\\OpenText\\Installed; the runner always invokes the wrapper (cost: ~1s of PowerShell launch when up-to-date) so the version constant lives in exactly one place: dependencies/opentext/version.txt.",
@@ -164,7 +174,7 @@
"Type": "EXE",
"InstallArgs": "",
"LogFile": "C:\\Logs\\PreInstall\\Setup-OpenText.log",
"PCTypes": ["*"]
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
},
{
"_comment": "UDC_Setup.exe spawns a hidden WPF window (UDC.exe) after install and never exits, so the runner needs KillAfterDetection: true to terminate UDC_Setup.exe + UDC.exe once the registry detection passes. This is an OPT-IN flag - normal installers should NOT set it because killing msiexec mid-install leaves msiserver holding the install mutex and the next msiexec call returns 1618 (Oracle hit this exact bug).",
@@ -196,7 +206,7 @@
"InstallArgs": "/qn /norestart ALLUSERS=1 REBOOT=ReallySuppress TARGETDIR=\"C:\\Program Files (x86)\\WJF_Defect_Tracker\"",
"DetectionMethod": "File",
"DetectionPath": "C:\\Program Files (x86)\\WJF_Defect_Tracker\\Defect_Tracker.exe",
"PCTypes": ["*"]
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
},
{
"_comment": "Shopfloor Standard serial-port drivers: StarTech PCIe serial adapter (MosChip-based) + Prolific PL2303 USB-to-serial. Install-Drivers.cmd runs pnputil /add-driver with /subdirs /install so every bundled INF under drivers/ lands in the Windows driver store and auto-binds to matching hardware present now or plugged in later. Installed on every PC type (PCTypes ['*']) because serial hardware turns up across bays; a serial driver on a PC without the hardware is harmless - it just sits in the driver store until matching hardware is plugged in.",
@@ -207,7 +217,29 @@
"LogFile": "C:\\Logs\\PreInstall\\Install-Drivers.log",
"DetectionMethod": "File",
"DetectionPath": "C:\\ProgramData\\PXEDrivers\\drivers-installed.marker",
"PCTypes": ["*"]
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
},
{
"_comment": "HP universal print driver, staged at imaging so a bay comes off the line able to build an HP queue without waiting on the SFLD share. Set-ShopdbPrinters.ps1 creates queues against drivers that are already present and SKIPS any that are not, so without this a freshly imaged bay silently gets no printers until a later enforcement cycle tops it up. NO WAVE GATE HERE, deliberately: waves exist because ~300 bays share one runtime share, whereas imaging runs a handful of bays at a time on the isolated PXE LAN that already moves multi-GB WIMs. Detection is the SPOOLER's driver key, not pnputil - pnputil only proves the package reached the driver store, and Get-PrinterDriver (what Set-ShopdbPrinters consults) reads the spooler, so a pnputil check goes green on a half-install. Payload is NOT in git: stage printer-drivers/ onto the PXE share like the other installer payloads.",
"Name": "ShopDB Printer Driver - HP Universal PS",
"Installer": "printer-drivers\\Install-ShopdbPrinterDrivers.ps1",
"Type": "PS1",
"InstallArgs": "-Only 'HP Universal Printing PS'",
"LogFile": "C:\\Logs\\PreInstall\\printer-drivers.log",
"DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Print\\Environments\\Windows x64\\Drivers\\Version-3\\HP Universal Printing PS",
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
},
{
"_comment": "Xerox universal print driver. Same reasoning as the HP entry above. These two universals are 100 MB combined and resolve the overwhelming majority of the fleet's printers; the four device-specific drivers (DesignJet, Zebra ZT411, Epson C3500, FARGO card printer) are another 126 MB and are deliberately NOT staged at imaging - they belong only on the bays that have that hardware, and those bays pick them up from the runtime manifest on their next enforcement cycle.",
"Name": "ShopDB Printer Driver - Xerox Global PCL6",
"Installer": "printer-drivers\\Install-ShopdbPrinterDrivers.ps1",
"Type": "PS1",
"InstallArgs": "-Only 'Xerox Global Print Driver PCL6'",
"LogFile": "C:\\Logs\\PreInstall\\printer-drivers.log",
"DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Print\\Environments\\Windows x64\\Drivers\\Version-3\\Xerox Global Print Driver PCL6",
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
}
]
}

View File

@@ -38,10 +38,6 @@
- gea-standard
- gea-engineer
- gea-shopfloor
- ge-standard
- ge-engineer
- ge-shopfloor-lockdown
- ge-shopfloor-mce
shopfloor_types:
- gea-shopfloor
# Image variants that share the generic Win10/Win11 unattend
@@ -50,8 +46,6 @@
standard_types:
- gea-standard
- gea-engineer
- ge-standard
- ge-engineer
deploy_subdirs:
- Applications
- Control
@@ -145,8 +139,8 @@
backup: yes
content: |
port=0
interface={{ pxe_iface }}
bind-interfaces
listen-address=172.16.9.1
bind-dynamic
dhcp-range=172.16.9.10,172.16.9.100,12h
# No default gateway (option 3) and no DNS (option 6) handed out:
# the PXE network is isolated and the PXE server does not forward
@@ -169,14 +163,18 @@
dhcp-option=6
enable-tftp
tftp-root={{ tftp_dir }}
# Arch-aware NBP: legacy BIOS PXE ROMs (client-arch=0) cannot run
# the EFI iPXE binary and report "NBP is too big to fit in free
# base memory" because ipxe.efi (~675KB) exceeds the BIOS PXE
# NBP cap. Serve undionly.kpxe (~70KB, BIOS-mode iPXE) to them
# instead. Everything else (UEFI x86_64 = arch 7 or 9, plus any
# future arches) keeps getting ipxe.efi - default-safe.
dhcp-match=set:bios,option:client-arch,0
dhcp-boot=tag:bios,undionly.kpxe
# Arch-aware NBP for Blancco/WinPE PXE clients:
# - legacy BIOS (vendorclass PXEClient:Arch:00000) -> ipxe.pxe, a
# BIOS full-feature iPXE build (boot.ipxe.org) with an HTTP/TCP
# stack. The old undionly.kpxe client-arch=0 scheme was stale -
# undionly.kpxe was never staged in tftp-root so it broke BIOS
# boot; this vendorclass+ipxe.pxe scheme is what runs on the box.
# - iPXE-running clients (userclass "iPXE") -> chain to the HTTP menu
# - everything else (UEFI x86_64) -> ipxe.efi
dhcp-vendorclass=set:bios,PXEClient:Arch:00000
dhcp-userclass=set:ipxe,iPXE
dhcp-boot=tag:bios,tag:!ipxe,ipxe.pxe
dhcp-boot=tag:ipxe,http://172.16.9.1:4433/Altiris/iPXE/GetPxeScript.aspx
dhcp-boot=tag:!bios,ipxe.efi
log-dhcp
# Per-lease state cleanup: flush conntrack + port-445 sockets for
@@ -265,6 +263,30 @@
boot
:blancco
iseq ${platform} pcbios && goto blancco_bios || goto blancco_efi
:blancco_bios
echo Loading Blancco for legacy BIOS (canonical)...
kernel http://${server}/blancco/vmlinuz-bde-linux initrd=initramfs-bde-linux.img,config.img,intel-ucode.img,amd-ucode.img archisobasedir=arch archiso_http_srv=http://${server}/blancco/ copytoram=y cow_spacesize=50% memtest=00 vmalloc=400M ip=dhcp libata.allow_tpm=1 modprobe.blacklist=iwlwifi,iwlmvm,btusb rd.udev.timeout=10 systemd.battery-check=0 || goto blancco_bios_failed
initrd --name intel-ucode.img http://${server}/blancco/intel-ucode.img || goto blancco_bios_failed
initrd --name amd-ucode.img http://${server}/blancco/amd-ucode.img || goto blancco_bios_failed
initrd --name config.img http://${server}/blancco/config.img || goto blancco_bios_failed
initrd --name initramfs-bde-linux.img http://${server}/blancco/initramfs-bde-linux.img || goto blancco_bios_failed
boot
:blancco_bios_failed
echo
echo Blancco BIOS boot FAILED.
prompt Press any key to return to menu... && goto menu
:blancco_efi
echo Loading Blancco UEFI (direct kernel)...
kernel http://${server}/blancco/vmlinuz-ubuntu initrd=kexec-initrd.img ip=dhcp nomodeset vga=normal console=tty0 i915.modeset=0 nouveau.modeset=0 modprobe.blacklist=nouveau,nvidiafb,nvidia,nvidia_drm,mei_me,btusb,dcdbas libata.allow_tpm=1 rd.udev.timeout=10 efi=noruntime usbcore.autosuspend=-1 pcie_aspm=off e1000e.IntMode=0 e1000e.eee_enable=0 consoleblank=0 systemd.battery-check=0 systemd.mask=suspend.target,sleep.target,hibernate.target,hybrid-sleep.target no_console_suspend mem_sleep_default=s2idle || goto blancco_efi_grub
initrd --name intel-ucode.img http://${server}/blancco/intel-ucode.img || goto blancco_efi_grub
initrd --name amd-ucode.img http://${server}/blancco/amd-ucode.img || goto blancco_efi_grub
initrd --name kexec-initrd.img http://${server}/blancco/kexec-initrd.img || goto blancco_efi_grub
boot
:blancco_efi_grub
echo Direct kernel boot failed - trying GRUB chain fallback...
chain http://${server}/blancco/grubx64.efi || goto secureboot_warn
:memtest
@@ -503,6 +525,14 @@
state: directory
mode: '0755'
- name: "Ensure driver store is owned by pxe (download-drivers.py pushes here over ssh as the pxe user; root/webapp still write it fine)"
file:
path: "/srv/samba/winpeapps/_shared/Out-of-box Drivers"
state: directory
owner: pxe
group: pxe
mode: '0775'
- name: "Deploy BIOS check script + manifest to winpeapps/_shared/BIOS/"
# Path matches what startnet.cmd reads at WinPE boot:
# net use B: \\172.16.9.1\winpeapps\_shared
@@ -555,6 +585,10 @@
follow symlinks = yes
wide links = yes
unix extensions = no
# Modern Samba silently disables wide links WITHOUT this. The shared
# image dirs are served via symlinks into _shared, so it is required
# or WinPE clients cannot follow them.
allow insecure wide links = yes
- name: "Samba SMB session handling for WinPE re-image robustness"
blockinfile:
@@ -630,6 +664,14 @@
level2 oplocks = no
strict sync = yes
[winpeapps_bios]
path = /srv/samba/winpeapps/_shared/BIOS
browseable = no
read only = yes
guest ok = no
valid users = pxe-upload
comment = Dell BIOS update bundle (read-only, served to WinPE)
- name: "Create Samba users (pxe-upload and blancco)"
shell: |
id pxe-upload >/dev/null 2>&1 || useradd -M -s /usr/sbin/nologin pxe-upload
@@ -693,17 +735,28 @@
loop: "{{ shopfloor_types }}"
ignore_errors: yes
# gea-standard (Win11) + gea-engineer (Win10) + ge-standard/engineer all use
# the same unattend. force: yes because drift between this file and the
# shared copies is what caused the Win10/Win11 search-cleanup regression
# earlier this session (d49f516) — prefer repo as source of truth.
- name: "Deploy standard/engineer unattend.xml"
# gea-standard and gea-engineer each get their OWN unattend. They are not
# interchangeable: the engineer file carries FirstLogonCommands Order 11,
# which installs the Engineering Layer, and nothing else differs. Deploying
# one shared template to both - which this task used to do - silently
# stripped that entry from gea-engineer on every playbook run.
#
# force: yes because drift between these files and the share is what caused
# the Win10/Win11 search-cleanup regression (d49f516). That only works if
# the repo copies are genuinely current: on 2026-08-19 this template was
# found to be missing specialize Orders 39-48 - including Order 39, which
# registers the bulk-enrollment orchestrator - so a playbook run would have
# removed enrollment from every standard and engineer bay. Both files are
# now reconciled against the live share. Re-check before trusting force: yes.
- name: "Deploy standard/engineer unattend.xml (per-type template)"
copy:
src: "{{ usb_mount }}/FlatUnattendW10.xml"
dest: "{{ samba_share }}/{{ item }}/Deploy/FlatUnattendW10.xml"
src: "{{ usb_mount }}/{{ item.src }}"
dest: "{{ samba_share }}/{{ item.dest }}/Deploy/FlatUnattendW10.xml"
mode: '0644'
force: yes
loop: "{{ standard_types }}"
loop:
- { src: 'FlatUnattendW10.xml', dest: 'gea-standard' }
- { src: 'FlatUnattendW10-engineer.xml', dest: 'gea-engineer' }
ignore_errors: yes
- name: "Daily cron to create/refresh Media.tag for all images"

170
playbook/scripts/preflight.ps1 Executable file
View File

@@ -0,0 +1,170 @@
# preflight.ps1 - check the things PESetup fails on, before it fails on them.
#
# Called by startnet.cmd once the media is mapped. Prints a short report a tech
# can read at the bay and exits non-zero if a check is fatal.
#
# WHY EACH CHECK IS HERE - all four come from PESetup's own behaviour
# (docs/PESETUP-INTERNALS.md, decompiled 4.0.0.17, observed on 4.0.0.20):
#
# Secure boot GatherDataSelection fails the step outright when
# SecurebootEnabled != 1. Hard failure, minutes into a build.
# Disk size MinRequiredSpaceWithoutCompression is 128849018880 (120 GB).
# Driver match GetDriverByModel returns null on a miss and PESetup logs a
# WARNING and keeps going. The bay images with no drivers, so no
# NIC, so DNS fails at first boot and enrollment cannot reach the
# CDN. The symptom appears far from the cause - this is the check
# that earns the script.
# Media age The media expires 30 days after build. PESetup shows days-left
# on a screen nobody reads.
#
# Lives on the enrollment share so it can be fixed without rebuilding boot.wim.
[CmdletBinding()]
param(
[string]$MediaDrive = 'Z:',
[int]$MinDiskGB = 120,
[int]$MediaWarnDays = 25
)
$ErrorActionPreference = 'Continue'
$fatal = 0
$warn = 0
function Ok { param($m) Write-Host (" [ OK ] " + $m) }
function Warn { param($m) Write-Host (" [WARN] " + $m); $script:warn++ }
function Fail { param($m) Write-Host (" [FAIL] " + $m); $script:fatal++ }
Write-Host ""
Write-Host "======== Pre-imaging checks ========"
# --- 1. Secure boot ------------------------------------------------------
# Confirm-SecureBootUEFI is not always present in WinPE; read the state the
# firmware exposes instead.
try {
$sb = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecureBoot\State' `
-Name UEFISecureBootEnabled -ErrorAction Stop
if ($sb.UEFISecureBootEnabled -eq 1) {
Ok "Secure boot enabled"
} else {
Fail "Secure boot is OFF. PESetup will fail at GatherData. Enable it in BIOS."
}
} catch {
Warn "Could not read secure boot state - if this is a legacy/CSM boot, PESetup will fail."
}
# --- 2. Disk size --------------------------------------------------------
try {
$disk = Get-CimInstance Win32_DiskDrive -ErrorAction Stop |
Where-Object { $_.MediaType -like '*Fixed*' } |
Sort-Object Index | Select-Object -First 1
if ($disk) {
$gb = [math]::Round($disk.Size / 1GB, 1)
if ($gb -ge $MinDiskGB) {
Ok ("Disk 0 is {0} GB ({1})" -f $gb, $disk.Model)
} else {
Fail ("Disk 0 is only {0} GB; PESetup needs {1} GB. ({2})" -f $gb, $MinDiskGB, $disk.Model)
}
} else {
Fail "No fixed disk found. PESetup has nothing to image."
}
} catch {
Warn "Could not enumerate disks: $_"
}
# --- 3. Driver match for THIS model --------------------------------------
# Reimplements GetDriverByModel: family filter first (it knows only Latitude,
# OptiPlex and Precision), then a substring test of comma-separated tokens,
# first match wins. Tokens are NOT trimmed, matching the C#.
try {
$model = (Get-CimInstance Win32_ComputerSystem -ErrorAction Stop).Model
$catalogue = Join-Path $MediaDrive 'Deploy\Control\HardwareDriver.json'
if (-not (Test-Path $catalogue)) {
Warn "HardwareDriver.json not found at $catalogue - cannot check drivers."
} else {
# NOT ConvertFrom-Json. HardwareDriver.json carries both casings of the
# same fields - "fileName" and "FileName", "destinationDir" and
# "DestinationDir". Windows PowerShell 5.1 (which is what WinPE runs)
# treats object keys case-insensitively and throws:
# "cannot convert the JSON string because a dictionary that was
# converted from the string contains the duplicated keys 'FileName'
# and 'FileName'"
# -AsHashtable would handle it but that is PowerShell 6+. So pull the
# four fields we need out of each entry by text instead. Prefers the
# lowercase key, falls back to the capitalised one.
$raw = Get-Content $catalogue -Raw
$entries = @()
foreach ($chunk in ([regex]::Split($raw, '\}\s*,\s*\{'))) {
$get = {
param($names)
foreach ($n in $names) {
$m = [regex]::Match($chunk, '"' + $n + '"\s*:\s*"((?:[^"\\]|\\.)*)"')
if ($m.Success) { return $m.Groups[1].Value -replace '\\\\', '\' }
}
return ''
}
$e = [pscustomobject]@{
modelswminame = (& $get @('modelswminame','models'))
family = (& $get @('family'))
fileName = (& $get @('fileName','FileName'))
destinationDir = (& $get @('destinationDir','DestinationDir'))
}
if ($e.modelswminame) { $entries += $e }
}
if (-not $entries.Count) {
Warn "Could not extract any entries from $catalogue - driver check skipped."
}
$family = ''
if ($model.ToUpper().Contains('LATITUDE')) { $family = 'Latitude' }
if ($model.ToUpper().Contains('OPTIPLEX')) { $family = 'Optiplex' }
if ($model.ToUpper().Contains('PRECISION')) { $family = 'Precision' }
$hit = $null
foreach ($e in $entries) {
if ($family -and ($e.family -notlike "*$family*")) { continue }
foreach ($tok in ([string]$e.modelswminame).Split(',')) {
if ($tok -and $model.ToLower().Contains($tok.ToLower())) { $hit = $e; break }
}
if ($hit) { break }
}
if ($hit) {
$zip = Join-Path $MediaDrive (([string]$hit.destinationDir) -replace '\*destinationdir\*\\?','')
$zip = Join-Path $zip $hit.fileName
if (Test-Path $zip) {
Ok ("Driver pack for '{0}': {1}" -f $model, $hit.fileName)
} else {
Fail ("Driver pack for '{0}' is listed but MISSING on the media: {1}" -f $model, $hit.fileName)
}
} else {
Fail ("NO driver pack matches '{0}'. PESetup logs this as a warning only - the bay will image with NO network drivers." -f $model)
}
}
} catch {
Warn "Driver check failed: $_"
}
# --- 4. Media age --------------------------------------------------------
# Approximate: PESetup expires media 30 days after build and Media.tag is
# rewritten when the media is rebuilt, so its timestamp is the best local proxy.
try {
$tag = Join-Path $MediaDrive 'Deploy\Control\Media.tag'
if (Test-Path $tag) {
$age = [int]((Get-Date) - (Get-Item $tag).LastWriteTime).TotalDays
if ($age -ge $MediaWarnDays) {
Warn ("Media is about {0} days old; it expires at 30. Rebuild it soon." -f $age)
} else {
Ok ("Media is about {0} days old" -f $age)
}
}
} catch { }
Write-Host "===================================="
if ($fatal -gt 0) {
Write-Host ""
Write-Host " $fatal BLOCKING problem(s) found. Imaging this bay will not work."
Write-Host ""
exit 1
}
if ($warn -gt 0) { Write-Host " $warn warning(s), no blockers." }
else { Write-Host " All checks passed." }
Write-Host ""
exit 0

View File

@@ -28,6 +28,12 @@ Latitude 7450|OptiPlex_7450_1.34.0.exe|1.34.0
Micro 7010|OptiPlex_7010_1.34.0_SEMB.exe|1.34.0
SFF Plus 7010|OptiPlex_7010_1.34.0_SEMB.exe|1.34.0
Tower Plus 7010|OptiPlex_7010_1.34.0_SEMB.exe|1.34.0
Micro 7020|OptiPlex_7020_1.22.1_SEMB.exe|1.22.1
Micro Plus 7020|OptiPlex_7020_1.22.1_SEMB.exe|1.22.1
SFF 7020|OptiPlex_7020_1.22.1_SEMB.exe|1.22.1
SFF Plus 7020|OptiPlex_7020_1.22.1_SEMB.exe|1.22.1
Tower 7020|OptiPlex_7020_1.22.1_SEMB.exe|1.22.1
Tower Plus 7020|OptiPlex_7020_1.22.1_SEMB.exe|1.22.1
Micro QCM1250|Dell_Pro_QBT1250_QBS1250_QBM1250_QCT1250_QCS1250_QCM1250_SEMB_1.12.2.exe|1.12.2
OptiPlex 3000|OptiPlex_3000_1.38.0.exe|1.38.0
OptiPlex 7000|OptiPlex_7000_1.38.0.exe|1.38.0
@@ -47,5 +53,4 @@ Precision 7820 Tower|Precision_7820_7920_2.50.0.exe|2.50.0
Precision 7865 Tower|Precision_7865_1.6.1.exe|1.6.1
Precision 7875 Tower|Precision_7875_SHP_02.07.03.exe|2.7.3
Rugged 14 RB14250|Dell_Pro_Rugged_RB14250_RA13250_1.13.1.exe|1.13.1
Tower Plus 7020|OptiPlex_7020_1.22.1_SEMB.exe|1.22.1
Tower Plus QBT1250|Dell_Pro_QBT1250_QBS1250_QBM1250_QCT1250_QCS1250_QCM1250_SEMB_1.12.2.exe|1.12.2

View File

@@ -0,0 +1,84 @@
<#
.SYNOPSIS
Return the tenant + PC purpose, pre-seeded if WinPE already asked.
.DESCRIPTION
Start-BulkEnrollOrchestrator.ps1 calls this at first logon and captures
stdout:
$pcType = powershell.exe -File 'C:\Deploy\Applications\BPRT\Select-PCConfig.ps1' -LogoPath '...'
It uses the answer twice - to pick which MCL package to apply, and to select
which bulk token Insert-BPRTToPPKG.ps1 injects (it matches on Tenant AND
Purpose). The vendor's version asks with a GUI, which means an otherwise
unattended build stops at a dialog until somebody walks over.
startnet.cmd now asks the same question at the PXE menu, while the tech who
knows what the machine is for is standing at it, and writes the answer to
C:\Enrollment\pc-config.txt. This shim returns that value when present and
falls through to the vendor dialog when it is not - so nothing is lost if the
tech picks "ask at first logon", or on a bay imaged before this existed.
ONLY the choice is pre-seeded. Token injection still happens at first logon:
Insert-BPRTToPPKG.ps1 fetches an encrypted token table from
mcl.dwcdn.geaerospace.com, which is unreachable from the isolated imaging
LAN.
.OUTPUTS
"{Tenant}_{PCType}" - Tenant GCCH|RoW, PCType PERS|SH|LOAN.
Identical contract to the vendor script.
.NOTES
The vendor script is kept alongside as Select-PCConfig-vendor.ps1. Replace
THAT file when the vendor ships a new one; this shim only needs changing if
the return contract changes.
#>
param(
[Parameter(Mandatory = $false)]
[string]$LogoPath
)
$seedFile = 'C:\Enrollment\pc-config.txt'
$vendor = Join-Path $PSScriptRoot 'Select-PCConfig-vendor.ps1'
$logDir = 'C:\Logs\BPRT\Orchestrator'
$shimLog = Join-Path $logDir 'Select-PCConfig-shim.log'
function Log {
param([string]$Message)
try {
New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
Add-Content -Path $shimLog -Value ("{0} {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message)
} catch { }
}
# Valid values, so a typo in the seed file falls back to the dialog instead of
# handing the orchestrator a string that matches no token and no package.
$validTenants = @('GCCH', 'RoW')
$validPurposes = @('PERS', 'SH', 'LOAN')
if (Test-Path $seedFile) {
$seed = (Get-Content $seedFile -First 1 -ErrorAction SilentlyContinue)
if ($seed) { $seed = $seed.Trim() }
$parts = if ($seed) { $seed.Split('_') } else { @() }
if ($parts.Count -eq 2 -and $validTenants -contains $parts[0] -and $validPurposes -contains $parts[1]) {
Log "Pre-seeded value '$seed' from $seedFile - dialog skipped."
Write-Output $seed
exit 0
}
Log "Ignoring malformed pre-seed '$seed' in $seedFile; falling back to the dialog."
}
if (Test-Path $vendor) {
Log "No usable pre-seed; showing the vendor dialog."
if ($LogoPath) { & $vendor -LogoPath $LogoPath }
else { & $vendor }
exit $LASTEXITCODE
}
# Neither a seed nor the vendor dialog. Say so in the log rather than returning
# something invented - the orchestrator would inject the wrong token.
Log "ERROR: no pre-seed at $seedFile and no vendor script at $vendor."
exit 1

View File

@@ -0,0 +1,409 @@
<#
.SYNOPSIS
Collect everything needed to diagnose an imaged bay, into one zip.
.DESCRIPTION
Read-only. Changes nothing on the machine.
Written because every imaging investigation so far has meant walking to a bay
and hand-copying files, and each time we discovered another thing we wished
we had grabbed at the same moment. This takes the lot in one pass.
Run it on a freshly imaged bay BEFORE lockdown.
This captures KNOWN-CURRENT, not known-good. A bay straight off the line has
applications on it that should not be there - preinstall.json entries without
a PCTypes filter install everywhere, so a Display bay picks up Adobe,
OpenText and Defect Tracker. The point of collecting is to have an exact
record of what imaging really produces, so the unnecessary items can be
identified and filtered out.
Before lockdown specifically, because afterwards you cannot tell whether
something is absent because lockdown removed it or because imaging never
installed it.
.PARAMETER Destination
Where to drop the zip. Defaults to C:\Logs. Point it at a share to collect
centrally, e.g. -Destination \\172.16.9.1\enrollment\imaging-logs
(only reachable while the bay is still on the imaging LAN).
.PARAMETER Label
Optional tag folded into the filename, e.g. -Label pre-lockdown.
.EXAMPLE
.\Collect-ImagingDiagnostics.ps1 -Label pre-lockdown
.EXAMPLE
.\Collect-ImagingDiagnostics.ps1 -Destination \\172.16.9.1\enrollment\imaging-logs
#>
[CmdletBinding()]
param(
[string]$Destination = 'C:\Logs',
[string]$Label = ''
)
$ErrorActionPreference = 'Continue'
$ProgressPreference = 'SilentlyContinue'
$serial = try { (Get-CimInstance Win32_BIOS).SerialNumber.Trim() } catch { 'unknown' }
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$name = if ($Label) { "$serial-$Label-$stamp" } else { "$serial-$stamp" }
$work = Join-Path $env:TEMP "imgdiag-$name"
New-Item -ItemType Directory -Path $work -Force | Out-Null
function Section { param([string]$File, [scriptblock]$Body)
$p = Join-Path $work $File
try { & $Body 2>&1 | Out-File -FilePath $p -Encoding utf8 -Width 500 }
catch { "COLLECTION ERROR: $_" | Out-File -FilePath $p -Encoding utf8 }
Write-Host (" {0}" -f $File)
}
function CopyTree { param([string]$Src, [string]$Dst)
# /XF *.ppkg and /MAX are not optional. run-enrollment harvests
# C:\ProgramData\Microsoft\Provisioning into C:\Logs\PPKG, so the 8 GB
# provisioning package exists TWICE under the trees we copy. Without these
# the collection is 16 GB and Compress-Archive dies with "stream was too
# long" - it cannot exceed 2 GB.
if (Test-Path $Src) {
$d = Join-Path $work $Dst
New-Item -ItemType Directory -Path $d -Force | Out-Null
robocopy $Src $d /E /R:0 /W:0 /NFL /NDL /NJH /NJS /XF *.ppkg *.wim *.iso /MAX:104857600 | Out-Null
Write-Host (" {0}\ <- {1}" -f $Dst, $Src)
}
}
Write-Host ""
Write-Host "Collecting imaging diagnostics for $serial ..."
Write-Host ""
# --- 1. Identity ---------------------------------------------------------
# ActiveComputerName is the name in use; ComputerName is the PENDING one. They
# differ when a rename is queued for the next reboot - which is exactly how the
# H<serial> -> F<serial> transition works, so capturing both tells you whether
# the package's rename landed or is still waiting.
Section 'identity.txt' {
'== computer names =='
'Active (in use): ' + (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\ComputerName\ActiveComputerName' -EA SilentlyContinue).ComputerName
'Pending (next boot): ' + (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\ComputerName\ComputerName' -EA SilentlyContinue).ComputerName
'NV Hostname: ' + (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters' -EA SilentlyContinue).'NV Hostname'
''
'== hardware =='
Get-CimInstance Win32_ComputerSystem | Select-Object Manufacturer, Model, Domain, PartOfDomain, TotalPhysicalMemory | Format-List
Get-CimInstance Win32_BIOS | Select-Object SerialNumber, SMBIOSBIOSVersion, ReleaseDate | Format-List
Get-CimInstance Win32_OperatingSystem | Select-Object Caption, Version, BuildNumber, InstallDate, LastBootUpTime | Format-List
''
'== uptime / install =='
'Windows installed: ' + (Get-CimInstance Win32_OperatingSystem).InstallDate
}
# --- 2. Enrollment state -------------------------------------------------
# dsregcmd is the authority on whether the Entra join actually happened.
# criticalChecks.json is the package's own self-assessment and the two can
# disagree - "Entra ID Joined: false" right after imaging is normal because the
# bay has not reached the production network yet.
Section 'enrollment.txt' {
'== dsregcmd /status =='
& dsregcmd /status
''
'== package self-checks =='
foreach ($f in 'C:\Logs\BPRT\criticalChecks.json','C:\Logs\BPRT\packageInfo.json',
'C:\Logs\BPRT\Orchestrator\TokenMatch.json') {
if (Test-Path $f) { "--- $f ---"; Get-Content $f -Raw }
}
''
'== staged selections =='
foreach ($f in 'C:\Enrollment\pc-type.txt','C:\Enrollment\display-type.txt',
'C:\Enrollment\pc-config.txt','C:\Enrollment\machine-number.txt',
'C:\Enrollment\fetch-source.txt') {
if (Test-Path $f) { "$f = " + ((Get-Content $f -First 1) -replace 'pxe$','<redacted>') }
}
}
# --- 3. Installed applications ------------------------------------------
# Registry Uninstall keys, both views. Deliberately NOT Win32_Product: querying
# it triggers an MSI reconfigure of every installed product, which is slow and
# can actually change the machine.
Section 'installed-apps.txt' {
$paths = @(
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'
)
Get-ItemProperty $paths -EA SilentlyContinue |
Where-Object { $_.DisplayName } |
Sort-Object DisplayName |
Select-Object DisplayName, DisplayVersion, Publisher, InstallDate, InstallLocation |
Format-Table -AutoSize
}
# --- 4. Everything that starts by itself --------------------------------
# THE section for "why did this app launch". Installers plant their own startup
# entries, so an app being installed IS an app being started - there is no
# separate switch. Capturing all four surfaces shows which app planted what.
# Note both registry views: a 32-bit installer's Run key lands under
# Wow6432Node and 64-bit tooling never sees it.
Section 'autostart.txt' {
'== HKLM Run / RunOnce (native + WOW64) =='
foreach ($k in 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run',
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce',
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run',
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\RunOnce') {
if (Test-Path $k) { "--- $k ---"; Get-ItemProperty $k | Format-List }
}
''
'== HKCU Run / RunOnce (current user) =='
foreach ($k in 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run',
'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce') {
if (Test-Path $k) { "--- $k ---"; Get-ItemProperty $k | Format-List }
}
''
'== Startup folders =='
foreach ($d in "$env:ProgramData\Microsoft\Windows\Start Menu\Programs\StartUp",
"$env:AppData\Microsoft\Windows\Start Menu\Programs\StartUp") {
if (Test-Path $d) { "--- $d ---"; Get-ChildItem $d | Select-Object Name, Length, LastWriteTime | Format-Table -AutoSize }
}
''
'== per-user Startup folders (all profiles) =='
Get-ChildItem 'C:\Users' -Directory -EA SilentlyContinue | ForEach-Object {
$d = Join-Path $_.FullName 'AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup'
if (Test-Path $d) { "--- $d ---"; Get-ChildItem $d | Select-Object Name | Format-Table -AutoSize }
}
''
'== enabled/disabled state (StartupApproved) =='
# Disabling a startup item via Task Manager or Settings does NOT remove the
# Run key or the Startup shortcut - it writes a flag here. So an entry can
# appear above and still be switched off. First byte 02/06 = enabled,
# 03/07 = disabled. Capturing this is what tells "imaging installed it and
# it runs" apart from "imaging installed it and somebody turned it off",
# which matters because the fix is not to install it at all.
foreach ($k in 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run',
'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\StartupFolder',
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run',
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run32',
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\StartupFolder') {
if (Test-Path $k) {
"--- $k ---"
$props = Get-Item $k
foreach ($n in $props.Property) {
$v = (Get-ItemProperty $k -Name $n).$n
$state = if ($v -is [byte[]] -and $v.Length -ge 1) {
switch ($v[0]) { 2 {'ENABLED'} 4 {'ENABLED'} 6 {'ENABLED'} 3 {'disabled'} 5 {'disabled'} 7 {'disabled'} default {"unknown(0x{0:X2})" -f $v[0]} }
} else { 'unknown' }
"{0,-10} {1}" -f $state, $n
}
}
}
''
'== scheduled tasks (non-Microsoft) =='
Get-ScheduledTask -EA SilentlyContinue |
Where-Object { $_.TaskPath -notlike '\Microsoft\*' } |
Select-Object TaskPath, TaskName, State,
@{n='Triggers';e={ ($_.Triggers | ForEach-Object { $_.CimClass.CimClassName }) -join ',' }},
@{n='Action';e={ ($_.Actions | ForEach-Object { $_.Execute }) -join ',' }} |
Sort-Object TaskPath, TaskName | Format-Table -AutoSize -Wrap
''
'== services set to auto-start (non-Microsoft paths) =='
Get-CimInstance Win32_Service -EA SilentlyContinue |
Where-Object { $_.StartMode -eq 'Auto' -and $_.PathName -notmatch 'C:\\Windows\\' } |
Select-Object Name, DisplayName, State, StartMode, PathName |
Sort-Object Name | Format-Table -AutoSize -Wrap
}
# --- 5. Kiosk / display specifics ---------------------------------------
Section 'display-kiosk.txt' {
'== kiosk shortcuts anywhere in Startup =='
Get-ChildItem 'C:\ProgramData\Microsoft\Windows\Start Menu\Programs\StartUp' -EA SilentlyContinue |
ForEach-Object {
$sh = (New-Object -ComObject WScript.Shell).CreateShortcut($_.FullName)
"{0}`n target: {1}`n args: {2}" -f $_.Name, $sh.TargetPath, $sh.Arguments
}
''
'== Edge policies =='
foreach ($k in 'HKLM:\SOFTWARE\Policies\Microsoft\Edge',
'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System') {
if (Test-Path $k) { "--- $k ---"; Get-ItemProperty $k | Format-List }
}
}
# --- 6. GE-Enforce -------------------------------------------------------
Section 'ge-enforce.txt' {
'== registry config =='
foreach ($k in 'HKLM:\SOFTWARE\GE\SFLD\Credentials','HKLM:\SOFTWARE\GE\SFLD\DSC',
'HKLM:\SOFTWARE\GE\ShopDB') {
# values only - do not dump anything that looks like a secret
if (Test-Path $k) {
"--- $k ---"
Get-Item $k | Select-Object -ExpandProperty Property | ForEach-Object {
$v = (Get-ItemProperty $k -Name $_).$_
if ($_ -match '(?i)key|token|secret|password|sas') { "$_ = <redacted, length $($v.ToString().Length)>" }
else { "$_ = $v" }
}
}
}
''
'== enforce client / DSC on disk =='
foreach ($d in 'C:\ProgramData\SFLD','C:\Deploy\Applications\BPRT') {
if (Test-Path $d) { "--- $d ---"; Get-ChildItem $d -Recurse -Depth 1 | Select-Object FullName, Length, LastWriteTime | Format-Table -AutoSize }
}
}
# --- 6b. IS IT ACTUALLY TALKING TO THE SHOPDB GE-ENFORCE API? -----------
# Config on disk proves nothing - the client can be present, configured, and
# never once succeed. This answers the actual question: is there a client, is it
# scheduled, has it run, and can this bay reach the endpoint right now.
#
# A 401 from the manifest endpoint is a GOOD result here: it proves DNS, routing
# and TLS all work and the service answered. Only a timeout or DNS failure means
# genuinely unreachable. No token is sent, so nothing here can enrol or change
# anything.
Section 'geenforce-api.txt' {
'== is the client installed? =='
$clientPaths = @(
'C:\Program Files\ShopDB', 'C:\ProgramData\ShopDB',
'C:\Deploy\Applications\BPRT\ShopdbEnforceClient.psm1',
'C:\Enrollment\ShopdbEnforceClient.psm1'
)
$found = $false
foreach ($p in $clientPaths) {
if (Test-Path $p) { $found = $true; "FOUND: $p"
Get-ChildItem $p -Recurse -EA SilentlyContinue |
Select-Object FullName, Length, LastWriteTime | Format-Table -AutoSize }
}
if (-not $found) { 'NOT FOUND - no ShopDB enforce client on this machine.' }
''
'== scheduled tasks that would run it =='
$tasks = Get-ScheduledTask -EA SilentlyContinue |
Where-Object { $_.TaskName -match '(?i)shopdb|ge-?enforce|asset report' }
if ($tasks) {
foreach ($t in $tasks) {
$i = $t | Get-ScheduledTaskInfo -EA SilentlyContinue
"{0}\{1} State={2} LastRun={3} LastResult={4} NextRun={5}" -f `
$t.TaskPath, $t.TaskName, $t.State, $i.LastRunTime, $i.LastTaskResult, $i.NextRunTime
($t.Actions | ForEach-Object { " action: $($_.Execute) $($_.Arguments)" })
}
} else { 'NONE - nothing scheduled to call the API.' }
''
'== configured base URL =='
$base = $null
foreach ($k in 'HKLM:\SOFTWARE\GE\ShopDB','HKLM:\SOFTWARE\GE\SFLD\Credentials') {
if (Test-Path $k) {
$p = Get-ItemProperty $k
foreach ($n in $p.PSObject.Properties.Name) {
# Match the NAME tightly and validate the VALUE looks like a URL.
# A loose "base" match picked up baseVersion=2.0.2 and the probe
# then tried to fetch "2.0.2/api/docs".
if ($n -match '(?i)^(baseurl|serverurl|shopdburl|endpoint|url)$') {
"$k\$n = $($p.$n)"
if (-not $base -and "$($p.$n)" -match '^https?://') { $base = $p.$n }
}
}
}
}
if (-not $base) { '(no BaseUrl configured - falling back to the known prod URL for the reachability test)' }
if (-not $base) { $base = 'https://tsgwp00525.wjs.geaerospace.net/shopdb' }
''
"== can this bay reach it right now? (base: $base) =="
$targets = @(
@{ Name = 'API docs'; Url = "$base/api/docs" },
@{ Name = 'GE-Enforce manifest'; Url = "$base/api/geenforce/manifest?pctype=gea-shopfloor-display" }
)
foreach ($t in $targets) {
try {
$sw = [Diagnostics.Stopwatch]::StartNew()
$r = Invoke-WebRequest -Uri $t.Url -UseBasicParsing -TimeoutSec 20 -EA Stop
$sw.Stop()
"{0,-22} HTTP {1} in {2} ms <- reachable" -f $t.Name, $r.StatusCode, $sw.ElapsedMilliseconds
} catch {
$code = try { $_.Exception.Response.StatusCode.value__ } catch { $null }
if ($code) {
"{0,-22} HTTP {1} <- REACHABLE (service answered; 401/403 just means no token was sent)" -f $t.Name, $code
} else {
"{0,-22} UNREACHABLE: {1}" -f $t.Name, $_.Exception.Message
' NOTE: ShopDB is only reachable once the bay has joined the AESFMA wifi SSID.'
' On the imaging LAN or plain wired, unreachable here is EXPECTED, not a fault.'
}
}
}
''
'== name resolution / route =='
try {
$h = ([uri]$base).Host
"host: $h"
Resolve-DnsName $h -EA Stop | Select-Object Name, Type, IPAddress | Format-Table -AutoSize
Test-NetConnection -ComputerName $h -Port 443 -InformationLevel Detailed -WarningAction SilentlyContinue |
Select-Object ComputerName, RemoteAddress, TcpTestSucceeded, PingSucceeded | Format-List
} catch { "DNS/route check failed: $_" }
''
'== client logs, if any =='
foreach ($d in 'C:\Logs\ShopDB','C:\Logs\GE-Enforce','C:\ProgramData\ShopDB\Logs') {
if (Test-Path $d) { "--- $d ---"; Get-ChildItem $d -Recurse | Select-Object FullName, Length, LastWriteTime | Format-Table -AutoSize }
}
}
# --- 7. Drivers, disk, network ------------------------------------------
Section 'drivers-disk-network.txt' {
'== third-party drivers =='
& pnputil /enum-drivers
''
'== disk layout =='
Get-Disk -EA SilentlyContinue | Format-Table -AutoSize
Get-Partition -EA SilentlyContinue | Format-Table -AutoSize
Get-Volume -EA SilentlyContinue | Format-Table -AutoSize
''
'== network =='
Get-NetAdapter -EA SilentlyContinue | Select-Object Name, InterfaceDescription, Status, LinkSpeed, MacAddress | Format-Table -AutoSize
Get-NetIPAddress -AddressFamily IPv4 -EA SilentlyContinue | Select-Object InterfaceAlias, IPAddress, PrefixOrigin | Format-Table -AutoSize
Get-DnsClientServerAddress -AddressFamily IPv4 -EA SilentlyContinue | Format-Table -AutoSize
}
# --- 8. Provisioning session state --------------------------------------
Section 'provisioning-sessions.txt' {
'== sessions =='
$k = 'HKLM:\SOFTWARE\Microsoft\Provisioning\Sessions'
if (Test-Path $k) {
Get-ChildItem $k | ForEach-Object {
$p = Get-ItemProperty $_.PSPath
"{0} State={1} RebootCount={2}" -f $_.PSChildName, $p.State, $p.RebootCount
}
} else { '(no provisioning sessions key)' }
}
# --- 9. Log trees --------------------------------------------------------
CopyTree 'C:\Logs' 'Logs'
CopyTree 'C:\Windows\Panther' 'Panther'
CopyTree 'C:\ProgramData\Microsoft\Provisioning' 'ProvisioningData'
foreach ($f in 'C:\Enrollment\winpe-staging.log','C:\Enrollment\setupcomplete.log') {
if (Test-Path $f) { Copy-Item $f $work -Force -EA SilentlyContinue }
}
# Enrollment dir listing only - it holds an 8 GB package we do not want.
Section 'enrollment-dir-listing.txt' {
if (Test-Path 'C:\Enrollment') {
Get-ChildItem 'C:\Enrollment' -Recurse -EA SilentlyContinue |
Select-Object FullName, Length, LastWriteTime | Format-Table -AutoSize
}
}
# --- 10. Provisioning event log -----------------------------------------
try {
$evtx = Join-Path $work 'Provisioning-Diagnostics-Admin.evtx'
& wevtutil epl 'Microsoft-Windows-Provisioning-Diagnostics-Provider/Admin' $evtx /ow:true 2>$null
if (Test-Path $evtx) { Write-Host ' Provisioning-Diagnostics-Admin.evtx' }
} catch { }
# --- zip -----------------------------------------------------------------
New-Item -ItemType Directory -Path $Destination -Force -EA SilentlyContinue | Out-Null
$zip = Join-Path $Destination "imgdiag-$name.zip"
try {
Compress-Archive -Path (Join-Path $work '*') -DestinationPath $zip -Force -EA Stop
Remove-Item $work -Recurse -Force -EA SilentlyContinue
Write-Host ""
Write-Host "Wrote $zip"
Write-Host ("Size: {0:N1} MB" -f ((Get-Item $zip).Length / 1MB))
} catch {
Write-Host ""
Write-Host "Could not zip to $Destination : $_"
Write-Host "Raw collection left at: $work"
}
Write-Host ""

View File

@@ -62,7 +62,7 @@ cmd /c "shutdown /a 2>nul" *>$null
# done work and continues from where it left off.
#
# Also top up AutoLogonCount so the SupportUser autologon budget
# (LogonCount=7 from unattend XML) survives extra unplanned reboots.
# (LogonCount=12 from unattend XML) survives extra unplanned reboots.
$selfResumeKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce'
$selfResumeName = 'ResumeRunShopfloorSetup'
$selfResumeCmd = 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "' + $PSCommandPath + '"'
@@ -94,9 +94,51 @@ $enrollDir = "C:\Enrollment"
$typeFile = Join-Path $enrollDir "pc-type.txt"
$setupDir = Join-Path $enrollDir "shopfloor-setup"
# NOTE: there is deliberately NO wait for an Entra join here - but NOT because
# shopfloor bays skip enrollment. They do enrol: the SFLD provisioning package
# joins Entra using the BPRT token it carries, and a human then assigns the
# device category in Intune.
#
# The wait is absent because the join cannot happen yet. At this point the bay is
# still on the isolated PXE LAN with no route to Entra - observed on 579C144
# 2026-08-06, holding 172.16.9.81 and 172.24.19.142, neither in the production
# ranges. sync_intune retries every 30 seconds until the tech re-cables to
# production, which is the right place to wait. An earlier version blocked here
# for 45 minutes and then warned about a failure that had not happened.
# "Entra ID Joined: false" in C:\Logs\BPRT\criticalChecks.json straight after
# imaging is therefore NORMAL, not a fault.
#
# CORRECTION (2026-08-06): this comment previously claimed shopfloor PCs are
# "vanilla by design" and that the orchestrator runs with -ManualFallback to skip
# BPRT injection and the package entirely. That was wrong and dangerous.
# -ManualFallback runs sysprep /oobe /reboot, so wiring it to an at-logon task
# syspreped finished machines seconds after autologon and destroyed the
# deployment chain. See docs/OWNERSHIP.md.
if (-not (Test-Path $typeFile)) {
Write-Host "No pc-type.txt found - skipping shopfloor setup."
exit 0
# A missing pc-type.txt means one of two very different things, and the old
# blanket "skip + exit 0" hid the bad one for weeks: four Display bays sat
# at imaging stage 2 with a green exit code and nobody noticed.
# - no C:\Enrollment at all -> this machine was never staged by WinPE
# (pre-imaging, or the staging block never ran). That is a FAILURE on a
# machine that has clearly just been imaged, so say so loudly.
# - C:\Enrollment exists but no pc-type.txt -> staging ran and the write
# failed. Also a failure.
$stagingLog = Join-Path $enrollDir 'winpe-staging.log'
$detail = if (Test-Path $enrollDir) {
"C:\Enrollment exists but pc-type.txt is missing - WinPE staging ran but did not write it. Check $stagingLog."
} else {
"C:\Enrollment does not exist - WinPE staging never ran. The Windows volume was probably not found in startnet.cmd, so pc-type.txt, the enrollment package and shopfloor-setup were ALL skipped."
}
Write-Host ""
Write-Host "================================================================"
Write-Host " FAILED: no pc-type.txt at $typeFile"
Write-Host " $detail"
Write-Host " Shopfloor setup cannot run. This PC is imaged but NOT configured."
Write-Host "================================================================"
Write-Host ""
Report-Stage -Stage 'Run-ShopfloorSetup: FAILED - no pc-type.txt' -Index 2 -Status 'failed' -Error_ $detail
exit 1
}
$pcType = (Get-Content $typeFile -First 1).Trim()
@@ -377,10 +419,14 @@ if ($noEnforceTypes -contains $pcType) {
Write-Warning "Register-GEEnforce.ps1 not found - no ongoing enforcement will run on this PC"
}
# Map S: drive on user logon for every account in BUILTIN\Users. The
# vendor 'SFLD - Consume Credentials' task is principal-restricted and
# does not fire for the ShopFloor end-user, so this parallel task fills
# the gap. Cross-PC-type because every shopfloor account needs S:.
# Register the 'GE Map SFLD Share' task: keeps S: alive for every account
# in BUILTIN\Users (logon + every 3 min + unlock/console connect + network
# up), with a real read probe and a confirm-then-repair path. The vendor
# 'SFLD - Consume Credentials' task DOES fire for ShopFloor (principal is
# the Users group) but maps S: once per logon only; nothing re-maps it
# mid-shift, which is why S: stayed dead until re-logon. Our task is
# idempotent next to it (healthy S: is never touched). GE-Enforce also
# (re)registers it fleet-wide via the common manifest entry.
# Display PCs skipped: kiosk user has no SFLD creds, S: map would fail
# every logon. Self-contained Display has no share dependency.
$registerMapShare = Join-Path $setupDir 'Shopfloor\Register-MapSfldShare.ps1'

View File

@@ -330,7 +330,16 @@ function Add-ShopfloorToolsApps {
# Kind = 'existing' -> copy an existing .lnk via Find-ExistingLnk
$cfgApps = Get-ProfileValue 'desktopApps'
if ($null -ne $cfgApps -and $cfgApps.Count -gt 0) {
# An EMPTY configured list means "no desktop apps" and must be honoured.
# Get-ProfileValue returns $null only when the key is absent from both the
# profile and site-config, so $null is the real "not configured" signal.
#
# The old test also required .Count -gt 0, which meant "desktopApps": []
# fell through to the hardcoded shopfloor list below - so a Display kiosk,
# which explicitly configures an empty list, was given UDC, eDNC, NTLARS,
# WJ Shopfloor and Defect_Tracker shortcuts. Configuring "none" produced
# "everything". Observed on 579C144, 2026-08-06.
if ($null -ne $cfgApps) {
$apps = @($cfgApps | ForEach-Object {
$entry = @{ Name = $_.name; Kind = $_.kind }
if ($_.kind -eq 'exe') { $entry.ExePath = $_.exePath }
@@ -556,6 +565,9 @@ if (Test-Path -LiteralPath $overridesPath) {
$cfgStartup = Get-ProfileValue 'startupItems'
# Same rule as desktopApps: $null = not configured, empty array = configured as
# none. Harmless here today because the else branch has no hardcoded fallback,
# but kept consistent so the two keys cannot drift apart again.
if ($null -ne $cfgStartup -and $cfgStartup.Count -gt 0) {
if (-not (Test-Path $startupDir)) {
New-Item -ItemType Directory -Path $startupDir -Force | Out-Null

View File

@@ -47,7 +47,15 @@ $layoutXmlPath = Join-Path $defaultUserShell 'LayoutModification.xml'
# ============================================================================
$cfgPins = Get-ProfileValue 'taskbarPins'
if ($null -ne $cfgPins -and $cfgPins.Count -gt 0) {
# An EMPTY configured list means "no pins" and must be honoured. Get-ProfileValue
# returns $null only when the key is absent from BOTH the profile and
# site-config, so $null is the real "not configured" signal. Requiring
# .Count -gt 0 as well makes "taskbarPins": [] fall through to the hardcoded
# shopfloor list - configuring "none" producing "everything", the same bug that
# put Plant Apps and Defect Tracker in a Display kiosk's Startup folder
# (Configure-PC.ps1) and machine-tool shortcuts on its desktop
# (06-OrganizeDesktop.ps1).
if ($null -ne $cfgPins) {
$pinSpec = @($cfgPins | ForEach-Object {
@{
Name = $_.name

View File

@@ -203,7 +203,13 @@ $cfgTabs = Get-ProfileValue 'edgeStartupTabs'
$startupTabs = @()
if ($null -ne $cfgTabs -and $cfgTabs.Count -gt 0) {
# Empty configured list = "no startup tabs", and must be honoured. $null (key
# absent from both profile and site-config) is the only "not configured" signal.
# Requiring .Count -gt 0 would make "edgeStartupTabs": [] fall through to the
# Plant Apps + WJ Shopfloor + Dashboard fallback below - the same
# configuring-none-gives-everything bug fixed in Configure-PC.ps1,
# 06-OrganizeDesktop.ps1 and 07-TaskbarLayout.ps1.
if ($null -ne $cfgTabs) {
foreach ($tab in $cfgTabs) {
$fallback = if ($tab.fallbackUrlKey -and $siteConfig.urls) { $siteConfig.urls.$($tab.fallbackUrlKey) } else { '' }
$url = Resolve-StartupUrl -BaseName $tab.baseName -Fallback $fallback
@@ -216,7 +222,7 @@ if ($null -ne $cfgTabs -and $cfgTabs.Count -gt 0) {
$shopFloorHome = Resolve-StartupUrl -BaseName 'WJ Shop Floor Homepage' -Fallback 'https://tsgwp00525.wjs.geaerospace.net'
if ($shopFloorHome) { $startupTabs += $shopFloorHome }
$dashboard = Resolve-StartupUrl -BaseName 'Shopfloor Dashboard' -Fallback 'https://tsgwp00525.wjs.geaerospace.net/shopdb/shopfloor-dashboard/'
$dashboard = Resolve-StartupUrl -BaseName 'Shopfloor Dashboard' -Fallback 'https://tsgwp00525.wjs.geaerospace.net/shopdb/shopfloor'
if ($dashboard) { $startupTabs += $dashboard }
}

View File

@@ -143,7 +143,21 @@ $edgePath = @(
$cfgItems = Get-ProfileValue 'startupItems'
if ($null -ne $cfgItems -and $cfgItems.Count -gt 0) {
# An EMPTY configured list means "no startup items" and must be honoured.
# Get-ProfileValue returns $null only when the key is absent from BOTH the
# profile and site-config, so $null is the real "not configured" signal.
#
# The old test also required .Count -gt 0, so "startupItems": [] fell through to
# the hardcoded list below and a Display kiosk was given UDC, eDNC, Defect
# Tracker, WJ Shopfloor and Plant Apps startup shortcuts - Plant Apps launching
# Edge in a new window on a kiosk. Confirmed on 579C144 2026-08-06:
#
# C:\ProgramData\...\StartUp\Defect Tracker.lnk 16:46:03
# C:\ProgramData\...\StartUp\Plant Apps.lnk 16:46:03
#
# Configuring "none" produced "everything". Same bug as desktopApps in
# 06-OrganizeDesktop.ps1 and taskbarPins in 07-TaskbarLayout.ps1.
if ($null -ne $cfgItems) {
$items = @()
$num = 0
foreach ($si in $cfgItems) {

View File

@@ -50,6 +50,36 @@ try {
}
} catch { Write-RegLog "Could not unregister legacy '$oldTaskName': $_" }
# PC types that have no machine number by design. Arming the prompt on these
# guarantees a dialog nobody can answer: startnet only collects a machine number
# for the machine-tool types, so everything else is left on the 9999 placeholder
# and the check below would otherwise fire on every one of them.
#
# Observed on 579C144 (a Display) 2026-08-06: PreInstall logged
# "Machine number: 9999 (default placeholder)" and the logon prompt armed itself
# on a kiosk with no keyboard.
$noMachineNumberTypes = @(
'gea-shopfloor-display'
)
$pcTypeFile = 'C:\Enrollment\pc-type.txt'
$pcType = ''
if (Test-Path -LiteralPath $pcTypeFile) {
$pcType = (Get-Content -LiteralPath $pcTypeFile -First 1 -ErrorAction SilentlyContinue)
if ($pcType) { $pcType = $pcType.Trim() }
}
if ($pcType -and ($noMachineNumberTypes -contains $pcType)) {
Write-RegLog "PC type '$pcType' has no machine number by design. Not registering the prompt."
foreach ($t in @($promptTaskName, $applyTaskName)) {
try {
if (Get-ScheduledTask -TaskName $t -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $t -Confirm:$false -ErrorAction Stop
Write-RegLog "Unregistered stale task '$t'"
}
} catch {}
}
return
}
# Only arm the tasks if the bay was imaged with the 9999 placeholder. If
# the tech entered a real machine number during PXE imaging it's already
# in C:\Enrollment\machine-number.txt; no prompt needed on first logon.

View File

@@ -1,31 +1,33 @@
# Register-MapSfldShare.ps1 - Stage Map-SfldShare.ps1 + register an
# HKLM\Run entry that maps S: for any interactive user (SupportUser,
# ShopFloor, any future end-user accounts).
# Register-MapSfldShare.ps1 - stage Map-SfldShare.ps1 and register the
# 'GE Map SFLD Share' scheduled task that keeps S: alive for every
# interactive BUILTIN\Users member (ShopFloor, SupportUser, ...).
#
# Why HKLM\Run instead of a scheduled task: Run fires at Explorer
# startup in the logged-in user's interactive session with their full
# token + HKCU mounted. No principal/LogonType/group-SID plumbing, no
# "task fires in session 0 but drive not visible to Explorer" class of
# bugs. Works for every BUILTIN\Users member with no extra logic.
#
# Why not the vendor's ConsumeCredentials.ps1: it calls
# New-StoredCredential -Persist LocalMachine (needs admin) before net
# use. ShopFloor is non-admin, so the cred-store fails and net use has
# no auth. Our Map-SfldShare.ps1 reads HKLM creds directly and passes
# them inline to net use /user: -- no Credential Manager needed.
# Replaces the HKLM\Run entry of the same name. Run fired once per logon and
# had no way to repair S: mid-shift; a task can fire at logon, on an
# interval, on unlock / console connect and when the network comes up.
# Principal is the Users GROUP with RunLevel Limited, so it runs in each
# logged-on member's own session with a non-elevated token: the mapping
# lands where Explorer and the apps look for it.
param(
[int]$IntervalMinutes = 3,
[string]$InstallRoot = 'C:\Program Files\GE\SfldShare',
# SHA256 the manifest entry detects on. When it differs from the share
# file the manifest was not bumped with the script; warn loudly once.
[string]$ExpectedHash = ''
)
$ErrorActionPreference = 'Continue'
$installRoot = 'C:\Program Files\GE\SfldShare'
$mapScript = Join-Path $installRoot 'Map-SfldShare.ps1'
$logDir = 'C:\Logs\SFLD'
$logFile = Join-Path $logDir 'register-mapshare.log'
$taskName = 'GE Map SFLD Share'
$runKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'
$runValue = 'GE Map SFLD Share'
$legacyTask = 'GE Shopfloor Map S: Drive'
$mapScript = Join-Path $InstallRoot 'Map-SfldShare.ps1'
$logDir = 'C:\Logs\SFLD'
$logFile = Join-Path $logDir 'register-mapshare.log'
if (-not (Test-Path $logDir)) { New-Item -Path $logDir -ItemType Directory -Force | Out-Null }
function Write-RegLog {
param([string]$Message)
$line = '[{0}] [INFO] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
@@ -33,47 +35,143 @@ function Write-RegLog {
Write-Host $line
}
Write-RegLog '=== Register-MapSfldShare start ==='
# Stage our Map-SfldShare.ps1 to a persistent location
if (-not (Test-Path $installRoot)) {
New-Item -Path $installRoot -ItemType Directory -Force | Out-Null
# Self-contained PC types never get S: (Display kiosk: local accounts, no
# SFLD creds, mapping could only fail). Gate here as well as at the call
# site - see the 579C144 note in the previous version.
$selfContainedTypes = @('Display', 'gea-shopfloor-display')
$pcType = ''
if (Test-Path -LiteralPath 'C:\Enrollment\pc-type.txt') {
$pcType = (Get-Content -LiteralPath 'C:\Enrollment\pc-type.txt' -First 1 -EA SilentlyContinue)
if ($pcType) { $pcType = $pcType.Trim() }
}
if ($pcType -and ($selfContainedTypes -contains $pcType)) {
Write-RegLog "PC type '$pcType' is self-contained - not registering the S: mapper."
Remove-ItemProperty -Path $runKey -Name $runValue -Force -EA SilentlyContinue
if (Get-ScheduledTask -TaskName $taskName -EA SilentlyContinue) { Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -EA SilentlyContinue }
exit 0
}
# --- stage the script ----------------------------------------------------
$src = Join-Path $PSScriptRoot 'lib\Map-SfldShare.ps1'
if (Test-Path $src) {
Copy-Item -Path $src -Destination $mapScript -Force
Write-RegLog "Staged $src -> $mapScript"
} else {
Write-RegLog "Map-SfldShare.ps1 not found at $src - cannot register"
exit 1
}
if (-not (Test-Path $src)) { Write-RegLog "Map-SfldShare.ps1 not found at $src - cannot register"; exit 1 }
$srcHash = (Get-FileHash -Path $src -Algorithm SHA256).Hash
$stampKey = 'HKLM:\SOFTWARE\GE\SfldShare'
# Remove the legacy scheduled task if it exists (left behind by older
# imaging runs that used the scheduled-task approach).
if (Get-ScheduledTask -TaskName $legacyTask -ErrorAction SilentlyContinue) {
try {
Unregister-ScheduledTask -TaskName $legacyTask -Confirm:$false -ErrorAction Stop
Write-RegLog "Removed legacy scheduled task '$legacyTask'"
} catch {
Write-RegLog "Failed to remove legacy task '$legacyTask': $_"
# Manifest / share drift guard. The GE-Enforce entry detects on
# MapperHash == DetectionValue, so a share edit without a DetectionValue bump
# makes the engine call this every cycle. Say so once per hash, and take the
# fast path below so that loop costs nothing and never churns the task.
if ($ExpectedHash -and ($ExpectedHash -ne $srcHash)) {
$warned = (Get-ItemProperty -Path $stampKey -Name DriftWarnedHash -EA SilentlyContinue).DriftWarnedHash
if ($warned -ne $srcHash) {
Write-RegLog "WARN: share Map-SfldShare.ps1 is $srcHash but the manifest DetectionValue is $ExpectedHash - bump DetectionValue in common/manifest.json or the engine re-runs this every cycle"
if (-not (Test-Path $stampKey)) { New-Item -Path $stampKey -Force | Out-Null }
Set-ItemProperty -Path $stampKey -Name DriftWarnedHash -Value $srcHash -Type String -EA SilentlyContinue
}
}
# Register HKLM\Run entry. Runs at Explorer startup for every
# interactive user in that user's session.
# Fast path: staged copy already matches the share and the task exists with
# our action -> nothing to (re)do. Re-stamp and leave (no Register -Force,
# so a live task is never torn down for no reason).
$taskNow = Get-ScheduledTask -TaskName $taskName -EA SilentlyContinue
if ($taskNow -and (Test-Path $mapScript) -and
((Get-FileHash -Path $mapScript -Algorithm SHA256).Hash -eq $srcHash) -and
($taskNow.Actions[0].Execute -like '*conhost.exe') -and
($taskNow.Actions[0].Arguments -like "*$mapScript*")) {
if (-not (Test-Path $stampKey)) { New-Item -Path $stampKey -Force | Out-Null }
Set-ItemProperty -Path $stampKey -Name MapperHash -Value $srcHash -Type String -EA SilentlyContinue
exit 0
}
Write-RegLog '=== Register-MapSfldShare start ==='
if (-not (Test-Path $InstallRoot)) { New-Item -Path $InstallRoot -ItemType Directory -Force | Out-Null }
Copy-Item -Path $src -Destination $mapScript -Force
Write-RegLog "Staged $src -> $mapScript"
# --- retire the older mechanisms -----------------------------------------
if (Get-ItemProperty -Path $runKey -Name $runValue -EA SilentlyContinue) {
Remove-ItemProperty -Path $runKey -Name $runValue -Force -EA SilentlyContinue
Write-RegLog "Removed HKLM\Run '$runValue' (superseded by the scheduled task)"
}
if (Get-ScheduledTask -TaskName $legacyTask -EA SilentlyContinue) {
Unregister-ScheduledTask -TaskName $legacyTask -Confirm:$false -EA SilentlyContinue
Write-RegLog "Removed legacy task '$legacyTask'"
}
# --- task ------------------------------------------------------------------
try {
$command = '"{0}" -NoProfile -ExecutionPolicy Bypass -File "{1}"' -f `
"$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe", $mapScript
# conhost --headless, not powershell -WindowStyle Hidden: on Win11 the
# default console host is Windows Terminal, which ignores the hosted
# app's Hidden style and flashes a window + steals foreground for
# ~0.5 s on every fire (measured in the VM). Headless conhost shows
# nothing and moves focus nowhere.
$ps = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
$action = New-ScheduledTaskAction -Execute "$env:SystemRoot\System32\conhost.exe" `
-Argument ('--headless "{0}" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "{1}"' -f $ps, $mapScript)
if (-not (Test-Path $runKey)) {
New-Item -Path $runKey -Force | Out-Null
$ns = 'Root/Microsoft/Windows/TaskScheduler'
$triggers = New-Object System.Collections.ArrayList
# 1. logon (any user)
[void]$triggers.Add((New-ScheduledTaskTrigger -AtLogOn))
# 2. interval, forever
$rep = New-ScheduledTaskTrigger -Once -At (Get-Date).Date -RepetitionInterval (New-TimeSpan -Minutes $IntervalMinutes)
[void]$triggers.Add($rep)
# 3. console connect (1) + session unlock (8)
$ssCls = Get-CimClass -ClassName MSFT_TaskSessionStateChangeTrigger -Namespace $ns
foreach ($state in 1, 8) {
$t = New-CimInstance -CimClass $ssCls -ClientOnly
$t.StateChange = $state
$t.Delay = 'PT10S'
[void]$triggers.Add($t)
}
New-ItemProperty -Path $runKey -Name $runValue -Value $command -PropertyType String -Force | Out-Null
Write-RegLog "Set $runKey\$runValue = $command"
# 4. network profile connected (NetworkProfile/Operational 10000) - wifi
# roam / cable back / VPN up. Short delay so DNS is usable.
$evCls = Get-CimClass -ClassName MSFT_TaskEventTrigger -Namespace $ns
$ev = New-CimInstance -CimClass $evCls -ClientOnly
$ev.Subscription = '<QueryList><Query Id="0" Path="Microsoft-Windows-NetworkProfile/Operational"><Select Path="Microsoft-Windows-NetworkProfile/Operational">*[System[(EventID=10000)]]</Select></Query></QueryList>'
$ev.Delay = 'PT15S'
[void]$triggers.Add($ev)
$principal = New-ScheduledTaskPrincipal -GroupId 'S-1-5-32-545' -RunLevel Limited
$settings = New-ScheduledTaskSettingsSet `
-MultipleInstances IgnoreNew `
-ExecutionTimeLimit (New-TimeSpan -Minutes 5) `
-StartWhenAvailable `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-Hidden
Register-ScheduledTask -TaskName $taskName `
-Action $action -Trigger $triggers.ToArray() -Principal $principal -Settings $settings `
-Force -ErrorAction Stop | Out-Null
Write-RegLog "Registered task '$taskName' (BUILTIN\Users, Limited; logon + every $IntervalMinutes min + unlock/connect + network-up)"
} catch {
Write-RegLog "FAILED to register Run key: $_"
Write-RegLog "FAILED to register task: $_"
exit 1
}
# --- stamp -----------------------------------------------------------------
# Written only after staging + registration both succeeded. The GE-Enforce
# manifest entry detects on MapperHash == SHA256 of Map-SfldShare.ps1, so a
# new script version (new hash) or a failed registration (no stamp) re-fires
# this registrar on the next cycle.
try {
$hash = (Get-FileHash -Path $mapScript -Algorithm SHA256).Hash
$stampKey = 'HKLM:\SOFTWARE\GE\SfldShare'
if (-not (Test-Path $stampKey)) { New-Item -Path $stampKey -Force | Out-Null }
Set-ItemProperty -Path $stampKey -Name MapperHash -Value $hash -Type String
Set-ItemProperty -Path $stampKey -Name RegisteredAt -Value (Get-Date -Format 's') -Type String
Set-ItemProperty -Path $stampKey -Name TaskName -Value $taskName -Type String
Write-RegLog "Stamped $stampKey MapperHash=$hash"
} catch {
Write-RegLog "Stamp write failed (registrar will re-run next cycle): $_"
}
Write-RegLog '=== Register-MapSfldShare end ==='
exit 0

View File

@@ -113,6 +113,28 @@ if ($siteConfig -and $siteConfig.pcProfiles -and $profileKey) {
}
}
}
# Subtype-strip fallback: a compound key like "gea-shopfloor-cmm-doda" (DODA
# CMM bays set pc-subtype.txt=doda) has no profile of its own. Fall back to
# the BARE pc-type ("gea-shopfloor-cmm" -> "CMM"). Without this the profile
# resolves to $null and callers silently use hardcoded defaults (wrong
# desktop icons + unwanted startup items).
if (-not $pcProfile -and $pcSubtype) {
$bare = $pcType
$pcProfile = $siteConfig.pcProfiles.$bare
if (-not $pcProfile) {
foreach ($g in $pcProfileAliasGroups) {
if ($g -icontains $bare) {
foreach ($alias in $g) {
if ($alias -ieq $bare) { continue }
$candidate = $siteConfig.pcProfiles.$alias
if ($candidate) { $pcProfile = $candidate; break }
}
break
}
}
}
if ($pcProfile) { $profileKey = $bare }
}
}
if ($pcProfile) {

View File

@@ -1,68 +1,305 @@
# Map-SfldShare.ps1 - Map S: drive on user logon using SFLD creds from HKLM.
# Map-SfldShare.ps1 - keep the SFLD share drive (S:) alive in the interactive
# user's session. Run by the 'GE Map SFLD Share' scheduled task as the
# logged-on user (BUILTIN\Users, Limited): at logon, every few minutes, on
# unlock / console connect, and when a network profile connects.
#
# Runs as the interactive user (BUILTIN\Users, Limited) so the drive
# mapping lands in the logged-in user's session. Reads username/password
# directly from HKLM:\SOFTWARE\GE\SFLD\Credentials\* and passes them
# inline to net use -- no Windows Credential Manager involvement.
# Rules (the drive is never touched while it works):
# healthy -> exit, no changes.
# no live mapping -> map now (nothing to interrupt).
# mapped but unreadable -> repair only after $ConfirmFailures consecutive
# failed probes, and only if TCP 445 answers.
# mapped to wrong target -> same confirm-then-repair path.
#
# Why not the vendor's ConsumeCredentials.ps1: it calls
# New-StoredCredential -Persist LocalMachine which requires admin.
# ShopFloor is a non-admin user, so the cred-store step fails silently
# and the subsequent net use (which relies on those stored creds) has
# no authentication. Direct net use /user: bypasses the issue entirely.
# Creds: HKLM:\SOFTWARE\GE\SFLD\Credentials\* entries that carry DriveLetter +
# ShareName (written by the vendor SetupCredentials package). The password is
# never put on a command line: New-SmbMapping runs in-process and the
# Credential Manager seed uses CredWrite via P/Invoke. The seed is what lets
# Windows' own logon-time restore and the redirector's transparent reconnect
# authenticate, so both stop producing red-X drives.
param(
[int]$ConfirmFailures = 2,
[int]$ProbeTimeoutSec = 8,
[switch]$Now # repair on first failed probe (manual / logon use)
)
$ErrorActionPreference = 'Continue'
# --- single instance per session ---------------------------------------
$mutex = New-Object System.Threading.Mutex($false, 'Local\GE-MapSfldShare')
if (-not $mutex.WaitOne(0)) { exit 0 }
# --- logging -----------------------------------------------------------
$logDir = 'C:\Logs\SFLD'
if (-not (Test-Path $logDir)) {
try { New-Item -Path $logDir -ItemType Directory -Force | Out-Null } catch { $logDir = $env:TEMP }
try {
if (-not (Test-Path $logDir)) { New-Item -Path $logDir -ItemType Directory -Force -EA Stop | Out-Null }
$probeFile = Join-Path $logDir ('.w-' + [guid]::NewGuid().ToString('N'))
[IO.File]::WriteAllText($probeFile, 'x'); Remove-Item $probeFile -Force -EA SilentlyContinue
} catch {
$logDir = Join-Path $env:LOCALAPPDATA 'GE\SfldShare'
if (-not (Test-Path $logDir)) { New-Item -Path $logDir -ItemType Directory -Force -EA SilentlyContinue | Out-Null }
}
$logFile = Join-Path $logDir 'map-share.log'
function Write-MapLog {
param([string]$Message)
$line = '[{0}] [{1}] {2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $env:USERNAME, $Message
param([string]$Message, [string]$Level = 'INFO')
$line = '[{0}] [{1}] [{2}] {3}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $env:USERNAME, $Level, $Message
Add-Content -Path $logFile -Value $line -ErrorAction SilentlyContinue
Write-Host $line
}
Write-MapLog '=== Map-SfldShare start ==='
# --- per-user state (fail counter, last state) --------------------------
$stateRoot = 'HKCU:\SOFTWARE\GE\SfldShare'
function Get-State {
param([string]$Letter)
$k = Join-Path $stateRoot $Letter
if (-not (Test-Path $k)) { New-Item -Path $k -Force | Out-Null }
$p = Get-ItemProperty -Path $k -EA SilentlyContinue
return @{
Key = $k
FailCount = [int]($p.FailCount)
LastState = [string]$p.LastState
CredHash = [string]$p.CredHash # SHA256(user + password) last seeded
}
}
function Set-State {
param([string]$Key, [int]$FailCount, [string]$LastState, [string]$CredHash = '')
Set-ItemProperty -Path $Key -Name FailCount -Value $FailCount -Type DWord -EA SilentlyContinue
Set-ItemProperty -Path $Key -Name LastState -Value $LastState -Type String -EA SilentlyContinue
Set-ItemProperty -Path $Key -Name LastRun -Value (Get-Date -Format 's') -Type String -EA SilentlyContinue
# only overwrite the seeded-credential hash when the caller supplies one
if ($CredHash) { Set-ItemProperty -Path $Key -Name CredHash -Value $CredHash -Type String -EA SilentlyContinue }
}
function Get-CredHash {
param([string]$User, [string]$Password)
$sha = [System.Security.Cryptography.SHA256]::Create()
$b = $sha.ComputeHash([Text.Encoding]::UTF8.GetBytes("$User`n$Password"))
$sha.Dispose()
return ([BitConverter]::ToString($b) -replace '-', '')
}
# --- probes -------------------------------------------------------------
function Test-Tcp445 {
param([string]$HostName, [int]$TimeoutMs = 3000)
try {
$c = New-Object System.Net.Sockets.TcpClient
$ar = $c.BeginConnect($HostName, 445, $null, $null)
$ok = $ar.AsyncWaitHandle.WaitOne($TimeoutMs)
if ($ok) { $c.EndConnect($ar) }
$c.Close()
return $ok
} catch { return $false }
}
# Real read of the drive root in a runspace with a hard timeout. Test-Path on
# a dead redirector connection can block for 30 s or more.
function Test-DriveReadable {
param([string]$Drive, [int]$TimeoutSec)
$ps = [powershell]::Create()
$null = $ps.AddScript({
param($d)
try {
$e = [System.IO.Directory]::EnumerateFileSystemEntries("$d\").GetEnumerator()
$null = $e.MoveNext()
'OK'
} catch { 'ERR: ' + $_.Exception.GetType().Name + ': ' + $_.Exception.Message }
}).AddArgument($Drive)
$h = $ps.BeginInvoke()
if ($h.AsyncWaitHandle.WaitOne($TimeoutSec * 1000)) {
$r = $ps.EndInvoke($h); $ps.Dispose()
return [string]$r[0]
}
try { $ps.Stop() } catch {}
$ps.Dispose()
return 'TIMEOUT'
}
# --- credential manager seed (CredWrite, no command line) ---------------
$script:credTypeLoaded = $false
function Set-StoredDomainCredential {
param([string]$Target, [string]$User, [string]$Password)
if (-not $script:credTypeLoaded) {
Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
public static class GeCredStore {
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct CREDENTIAL {
public uint Flags; public uint Type; public string TargetName; public string Comment;
public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
public uint CredentialBlobSize; public IntPtr CredentialBlob; public uint Persist;
public uint AttributeCount; public IntPtr Attributes; public string TargetAlias; public string UserName;
}
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
static extern bool CredWrite([In] ref CREDENTIAL cred, uint flags);
public static int Write(string target, string user, string password) {
byte[] blob = System.Text.Encoding.Unicode.GetBytes(password);
CREDENTIAL c = new CREDENTIAL();
c.Type = 2; // CRED_TYPE_DOMAIN_PASSWORD
c.Persist = 2; // CRED_PERSIST_LOCAL_MACHINE
c.TargetName = target; c.UserName = user; c.Comment = "GE SFLD share";
c.CredentialBlobSize = (uint)blob.Length;
c.CredentialBlob = Marshal.AllocHGlobal(blob.Length);
Marshal.Copy(blob, 0, c.CredentialBlob, blob.Length);
bool ok = CredWrite(ref c, 0);
int err = ok ? 0 : Marshal.GetLastWin32Error();
Marshal.FreeHGlobal(c.CredentialBlob);
return err;
}
}
'@ -ErrorAction Stop
$script:credTypeLoaded = $true
}
return [GeCredStore]::Write($Target, $User, $Password)
}
# --- mapping ------------------------------------------------------------
function New-Mapping {
param([string]$Drive, [string]$Unc, [string]$User, [string]$Password, [string]$HostName, [bool]$PreFlight = $false)
$letter = $Drive.TrimEnd(':')
# When a mapping (wrong or unreadable) is being replaced, prove the new
# one can be made BEFORE deleting the old: map the target to a spare
# letter, then drop it. A failed pre-flight leaves the old mapping in
# place. 1219 is the exception - it means the old mapping's own session
# is what blocks the new identity, so deleting it is the only way.
if ($PreFlight) {
$used = @((Get-PSDrive -PSProvider FileSystem -EA SilentlyContinue).Name) + @((Get-SmbMapping -EA SilentlyContinue).LocalPath | ForEach-Object { $_.TrimEnd(':') })
$spare = $null
foreach ($c in [char[]]'ZYXVUTQPONMLKJIHG') { if ($used -notcontains [string]$c) { $spare = "${c}:"; break } }
if ($spare) {
try {
New-SmbMapping -LocalPath $spare -RemotePath $Unc -UserName $User -Password $Password -Persistent $false -ErrorAction Stop | Out-Null
$ok = Test-DriveReadable -Drive $spare -TimeoutSec 8
& net use $spare /delete /y 2>&1 | Out-Null
if ($ok -ne 'OK') { return "pre-flight via $spare mapped but unreadable ($ok) - old $Drive left in place" }
} catch {
$m = $_.Exception.Message
if ($m -notmatch '1219|more than one user name|Multiple connections') {
return "pre-flight via $spare failed: $m - old $Drive left in place"
}
Write-MapLog "pre-flight hit 1219 - the existing $Drive session blocks the new identity; replacing it" 'WARN'
}
}
}
& net use $Drive /delete /y 2>&1 | Out-Null
Remove-Item -Path "HKCU:\Network\$letter" -Recurse -Force -EA SilentlyContinue
try {
New-SmbMapping -LocalPath $Drive -RemotePath $Unc -UserName $User -Password $Password -Persistent $true -ErrorAction Stop | Out-Null
return $null
} catch {
$msg = $_.Exception.Message
if ($msg -match '1219|more than one user name|Multiple connections') {
# stale session to the same host under another identity pins the
# credential; the hidden IPC$ connection is the pin.
Write-MapLog "1219 credential conflict on $HostName - dropping IPC`$ and retrying" 'WARN'
& net use "\\$HostName\IPC`$" /delete /y 2>&1 | Out-Null
try {
New-SmbMapping -LocalPath $Drive -RemotePath $Unc -UserName $User -Password $Password -Persistent $true -ErrorAction Stop | Out-Null
return $null
} catch { return $_.Exception.Message }
}
return $msg
}
}
# --- main ---------------------------------------------------------------
$credsBase = 'HKLM:\SOFTWARE\GE\SFLD\Credentials'
if (-not (Test-Path $credsBase)) {
Write-MapLog 'No HKLM SFLD credentials yet - exiting'
exit 0
}
if (-not (Test-Path $credsBase)) { Write-MapLog 'No HKLM SFLD credentials yet - exiting'; exit 0 }
$entries = @()
foreach ($entry in (Get-ChildItem -Path $credsBase -ErrorAction SilentlyContinue)) {
$p = Get-ItemProperty -Path $entry.PSPath -ErrorAction SilentlyContinue
if (-not $p -or -not $p.TargetHost -or -not $p.Username -or -not $p.Password) { continue }
$drive = $null
$share = $null
try { $drive = $p.DriveLetter } catch {}
try { $share = $p.ShareName } catch {}
$drive = [string]$p.DriveLetter; $share = [string]$p.ShareName
if ([string]::IsNullOrWhiteSpace($drive) -or [string]::IsNullOrWhiteSpace($share)) { continue }
$entries += [pscustomobject]@{
Drive = $drive.Trim().TrimEnd(':').ToUpper() + ':'
Unc = '\\{0}\{1}' -f $p.TargetHost, $share.TrimStart('\').TrimEnd('\')
Host = [string]$p.TargetHost
User = [string]$p.Username
Pass = [string]$p.Password
}
}
if ($entries.Count -eq 0) { exit 0 }
$drive = $drive.TrimEnd(':') + ':'
$share = $share.TrimStart('\')
$uncPath = "\\$($p.TargetHost)\$share"
$exit = 0
foreach ($e in $entries) {
$letter = $e.Drive.TrimEnd(':')
$st = Get-State -Letter $letter
# Skip if already mapped to the right target
$existing = & net use $drive 2>&1
if ($LASTEXITCODE -eq 0 -and ($existing -join "`n") -match [regex]::Escape($uncPath)) {
Write-MapLog "$drive already mapped to $uncPath - skipping"
$mapping = Get-SmbMapping -LocalPath $e.Drive -ErrorAction SilentlyContinue
$present = [bool]$mapping
$target = if ($mapping) { [string]$mapping.RemotePath } else { '' }
$wrong = $present -and ($target.TrimEnd('\') -ne $e.Unc)
# Seed Credential Manager whenever the HKLM credential differs from what
# this profile last seeded (first run, or a password rotation), and again
# after every repair. Keeps Windows' own logon-time restore authenticating.
$credHash = Get-CredHash -User $e.User -Password $e.Pass
if ($st.CredHash -ne $credHash) {
$rc = Set-StoredDomainCredential -Target $e.Host -User $e.User -Password $e.Pass
if ($rc -eq 0) {
Write-MapLog "Seeded Credential Manager for $($e.Host) as $($e.User)$(if ($st.CredHash) { ' (credential changed)' })"
Set-State -Key $st.Key -FailCount $st.FailCount -LastState $st.LastState -CredHash $credHash
} else { Write-MapLog "CredWrite for $($e.Host) failed (win32 $rc)" 'WARN' }
}
$probe = 'SKIPPED'
if ($present -and -not $wrong) { $probe = Test-DriveReadable -Drive $e.Drive -TimeoutSec $ProbeTimeoutSec }
if ($present -and -not $wrong -and $probe -eq 'OK') {
if ($st.LastState -ne 'healthy') { Write-MapLog "$($e.Drive) healthy -> $($e.Unc)" }
Set-State -Key $st.Key -FailCount 0 -LastState 'healthy'
continue
}
& net use $drive /delete /y 2>$null | Out-Null
$out = & net use $drive $uncPath /user:$($p.Username) $($p.Password) /persistent:yes 2>&1
if ($LASTEXITCODE -eq 0) {
Write-MapLog "Mapped $drive -> $uncPath"
# --- not healthy --------------------------------------------------
if (-not $present) {
Write-MapLog "$($e.Drive) has no live mapping - mapping now"
} elseif ($wrong) {
$st.FailCount++
Write-MapLog "$($e.Drive) points at '$target' not '$($e.Unc)' ($($st.FailCount)/$ConfirmFailures)" 'WARN'
} else {
Write-MapLog "FAILED $drive -> $uncPath (exit $LASTEXITCODE): $out"
$st.FailCount++
Write-MapLog "$($e.Drive) mapped (status $($mapping.Status)) but probe failed: $probe ($($st.FailCount)/$ConfirmFailures)" 'WARN'
}
if ($present -and -not $Now -and $st.FailCount -lt $ConfirmFailures) {
Set-State -Key $st.Key -FailCount $st.FailCount -LastState 'suspect'
continue
}
if (-not (Test-Tcp445 -HostName $e.Host)) {
Write-MapLog "$($e.Host):445 unreachable - not repairing $($e.Drive) this run" 'WARN'
Set-State -Key $st.Key -FailCount $st.FailCount -LastState 'unreachable'
$exit = 2
continue
}
Write-MapLog "Repairing $($e.Drive) -> $($e.Unc)$(if ($present) { ' (pre-flight first, existing mapping kept on failure)' })"
$err = New-Mapping -Drive $e.Drive -Unc $e.Unc -User $e.User -Password $e.Pass -HostName $e.Host -PreFlight $present
if ($err) {
Write-MapLog "Repair FAILED $($e.Drive): $err" 'ERROR'
Set-State -Key $st.Key -FailCount $st.FailCount -LastState 'failed'
$exit = 1
continue
}
$verify = Test-DriveReadable -Drive $e.Drive -TimeoutSec $ProbeTimeoutSec
if ($verify -eq 'OK') {
Write-MapLog "Repaired $($e.Drive) -> $($e.Unc) (verified readable)"
# re-seed after every repair so the stored credential matches what
# just proved to work
$rc = Set-StoredDomainCredential -Target $e.Host -User $e.User -Password $e.Pass
if ($rc -ne 0) { Write-MapLog "CredWrite after repair failed (win32 $rc)" 'WARN' }
Set-State -Key $st.Key -FailCount 0 -LastState 'healthy' -CredHash $credHash
} else {
Write-MapLog "Mapped $($e.Drive) but verify failed: $verify" 'ERROR'
Set-State -Key $st.Key -FailCount $st.FailCount -LastState 'failed'
$exit = 1
}
}
Write-MapLog '=== Map-SfldShare end ==='
exit 0
$mutex.ReleaseMutex() | Out-Null
exit $exit

View File

@@ -41,6 +41,135 @@ function Get-CurrentMachineNumber {
return $result
}
function Update-MTConnectVariantName {
<#
.SYNOPSIS
Renames an MTConnect device across every file of a variant that carries it.
.DESCRIPTION
Makino and Fanuc do not fit the one-file-one-service shape the other
variants do.
Per the vendor's "Installation Configuration and Trouble Shooting" guide,
the device name lives in several files that must be identical or the agent
may not start, and the Adapter Manager service rewrites the COMPLETE
configuration when it stops - so an edit made while it runs is thrown away.
Makino, from a real bay capture:
Agent\Devices.xml <Device name="7502" uuid="7502" id="MC41">
Agent\Devices.EDM.xml <Device name="MAKINO-1" uuid="MAKINO-1" id="EDM41">
Data\AdapterDataFile.xml <Name>MAKINO-1</Name>
Agent\Agent.cfg Adapters { MAKINO-1 { Host=localhost ... } }
Fanuc, likewise:
Agent\devices.xml <Device id="3101" name="3101" uuid="3101">
Adapter\devices.xml the adapter's own generated copy
Adapter\adapter.xml <DeviceName>/<DeviceID>/<DeviceUUID>3101
Editing only the agent's copy leaves the adapter streaming under the old
identity. Seven of thirty-four captured Fanuc bays are in that state -
agent on the right machine number, adapter still on the bay it was
imaged from (4007 on a 7801 Toshulin, 3031 on a 7804).
The vendor's own path is ConfigurationManager.exe, a GUI tool, which a
remote renumber cannot drive. This does what it does, in the required
order: stop the manager and the agent, rewrite, start again.
Anything that cannot be renamed is reported rather than half-applied -
a bay whose files disagree is worse than one still on its old name.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$NewNumber,
[Parameter(Mandatory)][hashtable[]]$Targets,
# Overridable so the rename can be exercised against a captured bay.
[Parameter(Mandatory)][string]$Root,
# Whatever owns the files goes down first - for Makino the Adapter
# Manager rewrites its configuration from memory as it stops, and for
# Fanuc the adapter holds its own copy. Overridable only so a test can
# drive the stop/start ordering with real services.
[string[]]$Services = @()
)
$out = @{ Updated = @(); Errors = @() }
$present = @($Targets |
ForEach-Object { @{ Path = (Join-Path $Root $_.Path); Kind = $_.Kind } } |
Where-Object { Test-Path -LiteralPath $_.Path })
if (-not $present) { return $out }
# Collect EVERY name in play, not just one. A bay half-renamed by the old
# code carries two at once - Devices.xml already on the machine number while
# the adapter files still say MAKINO-n - and replacing only the name we
# happened to discover leaves the other behind, which is the very state this
# is meant to end. Gather them all, then converge every file on the target.
$names = New-Object System.Collections.Generic.HashSet[string]
foreach ($t in $present) {
$raw = Get-Content -LiteralPath $t.Path -Raw -ErrorAction SilentlyContinue
if (-not $raw) { continue }
switch ($t.Kind) {
'attr' { if ($raw -match '<Device[^>]+name="([^"]+)"') { [void]$names.Add($matches[1]) } }
'name' { if ($raw -match '<Name>\s*([^<]+?)\s*</Name>') { [void]$names.Add($matches[1]) } }
'cfg' {
# The adapter block's key is the bare line inside Adapters { }.
if ($raw -match '(?ms)Adapters\s*\{\s*\r?\n\s*([^\s{]+)') { [void]$names.Add($matches[1]) }
}
}
}
[void]$names.Remove($NewNumber)
if ($names.Count -eq 0) { return $out } # every file already on the target
# Agent is stopped too, so it re-reads Agent.cfg on the way back up.
$stopped = @()
foreach ($name in $Services) {
$svc = Get-Service -Name $name -ErrorAction SilentlyContinue
if (-not $svc) { continue }
if ($svc.Status -ne 'Stopped') {
try {
Stop-Service -Name $name -Force -ErrorAction Stop
$stopped += $name
} catch {
$out.Errors += "MTConnect: could not stop '$name' ($_); nothing was changed"
foreach ($back in $stopped) {
try { Start-Service -Name $back -ErrorAction Stop } catch {}
}
return $out
}
}
}
foreach ($t in $present) {
try {
$content = Get-Content -LiteralPath $t.Path -Raw -ErrorAction Stop
$updated = $content
$from = @()
foreach ($old in $names) {
$escaped = [regex]::Escape($old)
switch ($t.Kind) {
'attr' { $next = $updated -replace ('"' + $escaped + '"'), ('"' + $NewNumber + '"') }
'name' { $next = $updated -replace ('<Name>\s*' + $escaped + '\s*</Name>'), ("<Name>$NewNumber</Name>") }
# The adapter block is keyed by a bare name on its own line.
'cfg' { $next = $updated -replace ('(?m)^(\s*)' + $escaped + '(\s*)$'), ("`${1}$NewNumber`${2}") }
# <DeviceName>/<DeviceID>/<DeviceUUID> in the Fanuc adapter config
'elem' { $next = $updated -replace ('(<Device(?:Name|ID|UUID)>)\s*' + $escaped + '\s*(</Device(?:Name|ID|UUID)>)'), ("`${1}$NewNumber`${2}") }
}
if ($next -ne $updated) { $from += $old; $updated = $next }
}
if ($updated -eq $content) { continue } # this file was already done
Set-Content -LiteralPath $t.Path -Value $updated -NoNewline -ErrorAction Stop
$out.Updated += "$($t.Path) ($($from -join ', ') -> $NewNumber)"
} catch {
$out.Errors += "MTConnect: rewrite failed for $($t.Path): $_"
}
}
foreach ($name in $stopped) {
try { Start-Service -Name $name -ErrorAction Stop }
catch { $out.Errors += "MTConnect: restart of '$name' failed: $_" }
}
return $out
}
function Update-MachineNumber {
<#
.SYNOPSIS
@@ -163,10 +292,13 @@ function Update-MachineNumber {
}
if ($udcSharePath) {
try {
$mountedUdc = Mount-SFLDShare -SharePath $udcSharePath -DriveLetter 'W:'
# N:, never W:. GE-Enforce owns W: for its whole cycle, and this
# runs as SYSTEM in the same drive namespace - taking W: here kills
# the share out from under an in-flight enforce cycle.
$mountedUdc = Mount-SFLDShare -SharePath $udcSharePath -DriveLetter 'N:'
if ($mountedUdc) {
try {
$bayDir = Join-Path 'W:\' $NewNumber
$bayDir = Join-Path 'N:\' $NewNumber
$srcCur = Join-Path $bayDir 'CurrentData.json'
$srcArc = Join-Path $bayDir 'ArchivedData'
if (Test-Path -LiteralPath $srcCur) {
@@ -226,7 +358,7 @@ function Update-MachineNumber {
Write-Host " Update-MachineNumber: no UDC backup at $bayDir (fresh PC, no prior data)"
}
} finally {
& net use W: /delete /y 2>$null | Out-Null
& net use N: /delete /y 2>$null | Out-Null
}
} else {
Write-Host " Update-MachineNumber: UDC backup share unreachable - skipping UDC restore."
@@ -304,11 +436,12 @@ function Update-MachineNumber {
# devices.xml / Devices.xml entries collapse to the same file). Without
# this filter, the Okuma branch on an Okuma PC sees the file already
# rewritten by the (no-op) Fanuc branch and skips the service restart.
# Okuma and eDNC keep the name in one file only - verified across 7 Okuma
# and 15 eDNC bay captures - so the simple edit is right for them. Fanuc and
# Makino are handled below instead.
$mtcVariants = @(
@{ Service='MTConnect Agent Fanuc'; Path='C:\MTConnect\Agent\devices.xml' },
@{ Service='MTConnect Agent Okuma'; Path='C:\MTConnect\Agent\Devices.xml' },
@{ Service='MTConnect eDNC Agent'; Path='C:\MTConnect_eDNC\Agent\Devices.xml' },
@{ Service='Makino MTConnect Agent'; Path='C:\Makino-MTConnect\Agent\Devices.xml' }
@{ Service='MTConnect Agent Okuma'; Path='C:\MTConnect\Agent\Devices.xml' },
@{ Service='MTConnect eDNC Agent'; Path='C:\MTConnect_eDNC\Agent\Devices.xml' }
)
foreach ($v in $mtcVariants) {
$svc = Get-Service -Name $v.Service -ErrorAction SilentlyContinue
@@ -343,6 +476,43 @@ function Update-MachineNumber {
}
}
# --- Multi-file variants: rename every copy, services stopped first ---
# Fanuc and Makino each keep the device name in more than one file, and the
# agent's copy alone is not enough - the adapter goes on streaming under the
# old identity, which is how seven captured Fanuc bays ended up with the
# agent on the right machine number and the adapter on the bay it was
# imaged from.
$multiFileVariants = @(
@{
Probe = 'MTConnect Agent Fanuc'
Root = 'C:\MTConnect'
Services = @('MTConnect Adapter Fanuc', 'MTConnect Agent Fanuc')
Targets = @(
@{ Path = 'Agent\devices.xml'; Kind = 'attr' },
@{ Path = 'Adapter\devices.xml'; Kind = 'attr' },
@{ Path = 'Adapter\adapter.xml'; Kind = 'elem' }
)
},
@{
Probe = 'Makino MTConnect Agent'
Root = 'C:\Makino-MTConnect'
Services = @('Makino Adapter Manager', 'Makino MTConnect Agent')
Targets = @(
@{ Path = 'Agent\Devices.xml'; Kind = 'attr' },
@{ Path = 'Agent\Devices.EDM.xml'; Kind = 'attr' },
@{ Path = 'Data\AdapterDataFile.xml'; Kind = 'name' },
@{ Path = 'Agent\Agent.cfg'; Kind = 'cfg' }
)
}
)
foreach ($variant in $multiFileVariants) {
if (-not (Get-Service -Name $variant.Probe -ErrorAction SilentlyContinue)) { continue }
$renamed = Update-MTConnectVariantName -NewNumber $NewNumber `
-Root $variant.Root -Targets $variant.Targets -Services $variant.Services
$out.MTConnectUpdated += $renamed.Updated
$out.Errors += $renamed.Errors
}
# Keep C:\Enrollment\machine-number.txt in sync. Post-imaging GE-Enforce
# prefers eDNC reg, but imaging-time scripts (Install-FromManifest
# TargetMachineNumbers filter, 01-eDNC.ps1, 03-RestoreEDncConfig.ps1)

View File

@@ -7,10 +7,12 @@ enrollment share. Runs in full Windows (reliable network), so it is immune to th
WinPE samba-idle-drop that loses copies during the WIM apply.
Covers the generic Fetch payload (shopfloor-setup tree + preinstall bundle) AND
the heavy per-type payload that Fetch-StagingPayload does NOT pull today: the CMM
bundle (C:\CMM-Install) and the selected bay's backup set
(C:\CMM-Install\backups\<cmmid>). That is the one that silently goes missing when
WinPE staging runs out of time before reboot.
the heavy per-type payloads that Fetch-StagingPayload does NOT pull today:
- CMM C:\CMM-Install (+ selected bay's backup C:\CMM-Install\backups\<cmmid>)
- Keyence C:\KeyenceInstall\<model> (MSI + Data*.cab)
- WaxTrace C:\WaxTrace-Install (bundle + bay-matched FormTracePak ISO)
These are the ones that silently go missing when WinPE staging runs out of time
(idle-dead Y: mount) before reboot.
Designed to be:
- run manually on a problem PC (Verify-And-Heal-Staging.bat), or
@@ -89,6 +91,31 @@ if ($pcType -eq 'gea-shopfloor-cmm') {
Add-Item "CMM backup ($cmmid)" "installers-post\cmm\backups\$cmmid" "C:\CMM-Install\backups\$cmmid" 'Dir' "C:\CMM-Install\backups\$cmmid" $null $true
}
}
# --- heavy Keyence payload (same gap as CMM: WinPE-only staged, never re-pulled
# by Fetch-StagingPayload). Only the selected model bundle lands under
# C:\KeyenceInstall\<model>. Verify on the model manifest so a missing/partial
# Data1.cab (the 700 MB - 2 GB payload msiexec SECREPAIR-hashes) gets re-pulled. ---
if ($pcType -eq 'gea-shopfloor-keyence') {
$kmodel = ReadTxt 'C:\Enrollment\keyence-model.txt'
if (-not $kmodel) { $kmodel = 'vr6000' }
Add-Item "Keyence bundle ($kmodel)" "installers-post\keyence\$kmodel" "C:\KeyenceInstall\$kmodel" 'Dir' "C:\KeyenceInstall\$kmodel\manifest.json"
}
# --- heavy WaxTrace payload (same gap as CMM/Keyence). Two parts, mirroring the
# three-step WinPE stage: (1) the bundle minus the formtracepak\ ISO dir, and
# (2) ONLY the bay's matched FORMTRACEPAK-V<ver>.iso, keyed on the version
# resolve-bay-config wrote to C:\Enrollment\waxtrace\version.txt during WinPE.
# If version.txt is missing (mount died before the resolver ran) the ISO cannot
# be re-pulled here - the bundle+resolver still heal, and resolve-bay-config can
# be re-run manually to regenerate version.txt then re-run this heal. ---
if ($pcType -eq 'gea-shopfloor-waxtrace') {
Add-Item 'WaxTrace bundle' 'installers-post\waxtrace' 'C:\WaxTrace-Install' 'Dir' 'C:\WaxTrace-Install\waxtrace-manifest.json' $null $false 'formtracepak'
$wtver = ReadTxt 'C:\Enrollment\waxtrace\version.txt'
if ($wtver) {
Add-Item "WaxTrace FTPak V$wtver" 'installers-post\waxtrace\formtracepak' 'C:\WaxTrace-Install\formtracepak' 'File' "C:\WaxTrace-Install\formtracepak\FORMTRACEPAK-V$wtver.iso" @("FORMTRACEPAK-V$wtver.iso")
} else {
Log 'WaxTrace: version.txt absent - cannot heal the bay-specific FormTracePak ISO (re-run resolve-bay-config then re-run heal)' 'WARN'
}
}
# --- robocopy-based verify/heal -----------------------------------------------
# Presence alone is NOT trusted: a partially transferred file (e.g. a truncated

View File

@@ -46,13 +46,76 @@ Write-EnforceLog '==============================================================
Write-EnforceLog "=== GE-Enforce session start (PID $PID, user $env:USERNAME) ==="
Write-EnforceLog '================================================================'
$logRoots = @('C:\Logs\Shopfloor', 'C:\Logs\SFLD', 'C:\Logs\Keyence')
# --- Roll undated logs so they can age ---
# The prune below drops a *.log by LastWriteTime. A log written to a FIXED
# filename is appended every cycle, so its LastWriteTime is always now and it
# can never be older than any cutoff - it grows forever. ntlars-backup.log,
# eventsaver.log and shopdb-collector-key.log all do this; eventsaver.log had
# reached 11,000 lines on a single PC.
#
# Rolling one to <name>-YYYYMMDD.log stops it being written to, so the prune
# takes it 30 days later, and the script that owns it needs no change: they all
# append with Add-Content or Tee-Object, which recreate a missing file on the
# next write. A file already carrying a date stamp is left alone.
#
# Rolled under the date of its FIRST LINE, so the stamp matches the lines
# inside. That timestamp is also the only trustworthy signal available:
# CreationTime is not, because of NTFS file tunneling - rename a file away and
# let the owner recreate it within 15 seconds, and the new file INHERITS the
# old creation time. Keyed on that, a busy log would look stale the moment it
# was rolled and would roll again every cycle, forever.
$rolledCount = 0
$today = (Get-Date).Date
foreach ($root in $logRoots) {
if (-not (Test-Path $root)) { continue }
Get-ChildItem -Path $root -Filter '*.log' -File -ErrorAction SilentlyContinue |
Where-Object { $_.BaseName -notmatch '\d{8}$' -and $_.Length -gt 0 } |
ForEach-Object {
$firstline = Get-Content -LiteralPath $_.FullName -TotalCount 1 -ErrorAction SilentlyContinue
# MUST be pre-typed. TryParse takes [ref][datetime], and passing a
# [ref] to an untyped $null throws "cannot find an overload", which
# would make this whole roll a silent no-op.
$started = [datetime]::MinValue
if ($firstline -match '(\d{4}-\d{2}-\d{2})') {
[void][datetime]::TryParse($matches[1], [ref]$started)
}
# No parseable date: fall back to size so a log with a format we do
# not recognise still cannot grow without limit.
if ($started -eq [datetime]::MinValue) {
if ($_.Length -lt 5MB) { return }
$started = $today.AddDays(-1)
}
if ($started.Date -ge $today) { return }
$stamp = $started.ToString('yyyyMMdd')
$target = Join-Path $_.DirectoryName ('{0}-{1}.log' -f $_.BaseName, $stamp)
# An existing target means the roll already ran for that day; append
# to it rather than losing either side.
try {
if (Test-Path -LiteralPath $target) {
Get-Content -LiteralPath $_.FullName -ErrorAction Stop |
Add-Content -LiteralPath $target -ErrorAction Stop
Remove-Item -LiteralPath $_.FullName -Force -ErrorAction Stop
} else {
Move-Item -LiteralPath $_.FullName -Destination $target -ErrorAction Stop
}
$rolledCount++
} catch {}
}
}
if ($rolledCount -gt 0) {
Write-EnforceLog "Rolled $rolledCount undated log file(s) so retention can age them"
}
# --- Log retention prune ---
# Drops *.log files older than $retentionDays from the shopfloor log roots.
# Cheap (flat dir scan, no recursion). Runs every cycle. Today's
# enforce-YYYYMMDD.log is never touched (LastWriteTime = now).
$retentionDays = 30
$prunedCount = 0
foreach ($root in @('C:\Logs\Shopfloor', 'C:\Logs\SFLD', 'C:\Logs\Keyence')) {
foreach ($root in $logRoots) {
if (-not (Test-Path $root)) { continue }
$cutoff = (Get-Date).AddDays(-$retentionDays)
Get-ChildItem -Path $root -Filter '*.log' -File -ErrorAction SilentlyContinue |

View File

@@ -241,6 +241,24 @@ function Test-AppInstalled {
# ---------------------------------------------------------------------------
# Action dispatch
# ---------------------------------------------------------------------------
function Join-InstallerPath {
# Plain string math, NOT Join-Path. Join-Path resolves the drive qualifier
# through the PS provider and emits NOTHING (null) if that drive vanished
# mid-cycle - the null then binds into Test-Path -LiteralPath and throws
# "Cannot bind argument to parameter 'LiteralPath' because it is null".
# [IO.Path]::Combine is no good either: its separator and rooted-path rules
# follow the host platform, so it cannot be validated off-Windows.
# Returns $null when the entry is unusable; every caller treats null as
# "not found" and logs, instead of crashing the entry.
param([string]$Root, [string]$Rel)
if ([string]::IsNullOrWhiteSpace($Root) -or [string]::IsNullOrWhiteSpace($Rel)) { return $null }
$clean = ($Rel -replace '/', '\').Trim()
# Reject rooted values ('\x', 'D:\x', '\\server\share'): a manifest entry
# must resolve UNDER the share root, never escape it.
if ($clean -match '^(\\|[A-Za-z]:)') { return $null }
return ($Root.TrimEnd('\') + '\' + $clean)
}
function Invoke-InstallerAction {
param($App)
@@ -251,8 +269,8 @@ function Invoke-InstallerAction {
switch ($App.Type) {
'MSI' {
$installerPath = Join-Path $InstallerRoot $App.Installer
if (-not (Test-Path -LiteralPath $installerPath)) {
$installerPath = Join-InstallerPath $InstallerRoot $App.Installer
if ([string]::IsNullOrWhiteSpace($installerPath) -or -not (Test-Path -LiteralPath $installerPath)) {
Write-InstallLog " MSI not found: $installerPath" 'ERROR'
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
}
@@ -270,8 +288,8 @@ function Invoke-InstallerAction {
return [pscustomobject]@{ ExitCode = $proc.ExitCode; LogRef = $msiLog }
}
'EXE' {
$installerPath = Join-Path $InstallerRoot $App.Installer
if (-not (Test-Path -LiteralPath $installerPath)) {
$installerPath = Join-InstallerPath $InstallerRoot $App.Installer
if ([string]::IsNullOrWhiteSpace($installerPath) -or -not (Test-Path -LiteralPath $installerPath)) {
Write-InstallLog " EXE not found: $installerPath" 'ERROR'
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
}
@@ -346,8 +364,8 @@ function Invoke-InstallerAction {
return [pscustomobject]@{ ExitCode = $exitCode; LogRef = $App.LogFile }
}
{ $_ -eq 'CMD' -or $_ -eq 'BAT' } {
$installerPath = Join-Path $InstallerRoot $App.Installer
if (-not (Test-Path -LiteralPath $installerPath)) {
$installerPath = Join-InstallerPath $InstallerRoot $App.Installer
if ([string]::IsNullOrWhiteSpace($installerPath) -or -not (Test-Path -LiteralPath $installerPath)) {
Write-InstallLog " CMD/BAT not found: $installerPath" 'ERROR'
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
}
@@ -369,8 +387,8 @@ function Invoke-InstallerAction {
Write-InstallLog (" PS1 entry '{0}' has no Script/Installer value (Script={1}, Installer={2}) - skipping" -f $App.Name, $App.Script, $App.Installer) 'ERROR'
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
}
$scriptPath = Join-Path $InstallerRoot $rel
if (-not (Test-Path -LiteralPath $scriptPath)) {
$scriptPath = Join-InstallerPath $InstallerRoot $rel
if ([string]::IsNullOrWhiteSpace($scriptPath) -or -not (Test-Path -LiteralPath $scriptPath)) {
Write-InstallLog " PS1 not found: $scriptPath (from rel '$rel')" 'ERROR'
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
}
@@ -384,8 +402,8 @@ function Invoke-InstallerAction {
return [pscustomobject]@{ ExitCode = $proc.ExitCode; LogRef = $null }
}
'INF' {
$infPath = Join-Path $InstallerRoot $App.Installer
if (-not (Test-Path -LiteralPath $infPath)) {
$infPath = Join-InstallerPath $InstallerRoot $App.Installer
if ([string]::IsNullOrWhiteSpace($infPath) -or -not (Test-Path -LiteralPath $infPath)) {
Write-InstallLog " INF not found: $infPath" 'ERROR'
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
}
@@ -397,8 +415,8 @@ function Invoke-InstallerAction {
}
'File' {
# Copy a file from the share (configs/*) to an absolute on-PC path.
$source = Join-Path $InstallerRoot $App.Source
if (-not (Test-Path -LiteralPath $source)) {
$source = Join-InstallerPath $InstallerRoot $App.Source
if ([string]::IsNullOrWhiteSpace($source) -or -not (Test-Path -LiteralPath $source)) {
Write-InstallLog " File source not found: $source" 'ERROR'
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
}
@@ -625,6 +643,18 @@ $skipped = 0
$failed = 0
$pcFiltered = 0
# Per-entry outcomes for the caller's report (the API enforce runner captures
# the object emitted at the end). SMB/GE-Enforce.ps1 ignores stdout + reads the
# exit code, so this is additive and does not change the share path.
$script:enforceResults = [System.Collections.Generic.List[object]]::new()
function Add-EnforceResult {
param([string]$Name, [string]$Action, [int]$ExitCode = 0,
[bool]$SelfHealed = $false, [string]$Message = '')
$script:enforceResults.Add([pscustomobject]@{
Name = $Name; Action = $Action; ExitCode = $ExitCode
SelfHealed = $SelfHealed; Message = $Message })
}
foreach ($app in $config.Applications) {
# Cancel any reboot that a prior MSI queued, so the enforcer never
# triggers an unexpected restart on a shopfloor PC.
@@ -640,12 +670,14 @@ foreach ($app in $config.Applications) {
if (-not (Test-PCTypeMatches -App $app -Type $PCType -SubType $PCSubType)) {
Write-InstallLog " PCTypes filter: entry targets $($app.PCTypes -join ',') but PC is $PCType$(if ($PCSubType) { "-$PCSubType" }) - skipping"
$pcFiltered++
Add-EnforceResult -Name $app.Name -Action 'filtered' -Message 'PCTypes filter'
continue
}
if (-not (Test-HostnameMatches -App $app)) {
Write-InstallLog " TargetHostnames filter: entry targets $($app.TargetHostnames -join ',') but PC is $([System.Environment]::MachineName) - skipping"
$pcFiltered++
Add-EnforceResult -Name $app.Name -Action 'filtered' -Message 'TargetHostnames filter'
continue
}
@@ -653,6 +685,7 @@ foreach ($app in $config.Applications) {
$myNum = Get-CurrentMachineNumber
Write-InstallLog " TargetMachineNumbers filter: entry targets $($app.TargetMachineNumbers -join ',') but machine number is $(if ($myNum) { $myNum } else { '(none)' }) - skipping"
$pcFiltered++
Add-EnforceResult -Name $app.Name -Action 'filtered' -Message 'TargetMachineNumbers filter'
continue
}
@@ -660,12 +693,14 @@ foreach ($app in $config.Applications) {
$myVer = Get-CurrentCmmVersion
Write-InstallLog " _CmmVersion filter: entry targets $($app._CmmVersion) but bay version is $(if ($myVer) { $myVer } else { '(none)' }) - skipping"
$pcFiltered++
Add-EnforceResult -Name $app.Name -Action 'filtered' -Message '_CmmVersion filter'
continue
}
if (Test-AppInstalled -App $app) {
Write-InstallLog ' Already installed at expected version - skipping'
$skipped++
Add-EnforceResult -Name $app.Name -Action 'skipped' -Message 'already installed'
continue
}
@@ -705,6 +740,12 @@ foreach ($app in $config.Applications) {
if ($rc -eq 1641) { Write-InstallLog " (Installer initiated a reboot for $($app.Name))" }
if ($rc -eq 259) { Write-InstallLog ' (pnputil: no newer driver found - considered installed)' }
$installed++
# SelfHealed = a real drift correction (a detected-missing entry we
# re-installed). Always/no-detection entries install every cycle by
# design and are not self-heals, so the report stays 'ok' for them.
Add-EnforceResult -Name $app.Name -Action 'installed' -ExitCode $rc `
-SelfHealed ([bool]($app.DetectionMethod -and $app.DetectionMethod -ne 'Always')) `
-Message "Exit $rc"
# Auto-write marker file for MarkerFile-detected entries that just
# completed successfully. Keeps one-shot PS1 scripts from running
@@ -749,11 +790,13 @@ foreach ($app in $config.Applications) {
}
$failed++
Add-EnforceResult -Name $app.Name -Action 'failed' -ExitCode $rc -Message "Exit $rc - FAILED"
}
} catch {
Write-InstallLog (" UNCAUGHT error processing {0}: {1} | at {2}" -f $app.Name, $_.Exception.Message, ($_.ScriptStackTrace -replace '\s+',' ')) 'ERROR'
$failed++
Add-EnforceResult -Name $app.Name -Action 'failed' -Message $_.Exception.Message
}
}
@@ -763,5 +806,17 @@ Write-InstallLog '============================================'
cmd /c 'shutdown /a 2>nul' *>$null
# Emit the summary object for the API enforce runner to report. Write-Host log
# lines above go to the host stream, so this is the only value on the success
# stream that '& $EnginePath' captures. The exit code is unchanged (SMB path).
Write-Output ([pscustomobject]@{
Installed = $installed
Skipped = $skipped
Failed = $failed
Filtered = $pcFiltered
EnforcerVersion = "$LIB_MANIFEST_MAJOR.$LIB_MANIFEST_MINOR"
Results = $script:enforceResults.ToArray()
})
if ($failed -gt 0) { exit 1 }
exit 0

View File

@@ -0,0 +1,526 @@
# ShopdbBackupClient - the client half of the ShopDB asset-backup contract.
#
# WHY THIS EXISTS
#
# ShopDB's backups plugin is a pluggable registry: a "kind" declares how a
# config is parsed, rendered and resolved to an asset, and in return gets
# revision history, content dedup, retention, diffs and an asset panel. The
# server half is a clean contract. The client half was not: exactly one script
# on this share posted backups (Backup-NtlarsSettings), and everything around
# the post - finding the collector key, reading the interval, throttling,
# logging - lived inside it, bespoke.
#
# The cost showed up the day someone looked. The interval came from a public
# settings key the plugin never declared public, so the read silently fell back
# to a hardcoded 24 hours and the setting did nothing for months. The log wrote
# three lines every five minutes whatever happened, reaching 3,234 lines of
# which 3,217 were the same "Throttled" line. Seven other Backup-*.ps1 scripts
# on this share capture device configs and post NONE of them, so nothing about
# a CMM, a PC-DMIS bay or an MTConnect box has any history in ShopDB.
#
# Wiring those seven the old way would have produced seven more copies of all
# of the above. So the shared parts live here, once, and a per-device script
# becomes: capture the config, call Send-ShopdbBackup.
#
# USAGE
#
# Import-Module "$PSScriptRoot\..\lib\ShopdbBackupClient.psm1" -Force
# $ctx = Initialize-ShopdbBackup -Kind 'gocmm'
# if (-not $ctx.Proceed) { exit 0 } # quiet exit, already logged
# $bytes = [IO.File]::ReadAllBytes($configPath)
# Send-ShopdbBackup -Context $ctx -Bytes $bytes -SourceFileName 'settings.xml'
#
# Initialize-ShopdbBackup does every check that can say "nothing to do today":
# machine number, collector key, base URL, throttle. Each of those is logged
# ONCE and then stays quiet while it holds, so a PC with no NTLARS does not
# write the same line 288 times a day.
#
# PER-KIND STATE, deliberately. Marker, state and log files are all named from
# the kind, so a bay running two backup kinds does not have them fighting over
# one marker file - which is what a single fixed name would have caused the
# first time a second kind shipped.
Set-StrictMode -Version Latest
$script:LOGDIR = 'C:\Logs\Shopfloor'
$script:SHOPDBREG = 'HKLM:\SOFTWARE\GE\ShopDB'
$script:KEYFILE = 'C:\Enrollment\shopdb-key.txt'
$script:PCCONFIG = 'C:\Enrollment\pc-config.txt'
# The DEVICE this PC drives, by asset number - a part marker, a CMM, a Keyence.
# Written at staging and read by the computers collector, which resolves it with
# _asset_by_number and refuses a name it cannot find rather than minting a
# phantom. Marker and measuring-tool bays have no machine number of their own
# ("keyence / genspect / part-marker have no per-bay id" in
# Report-AssetToShopDB.ps1), so for those this file IS the identity.
$script:ASSETIDFILE = 'C:\Enrollment\asset-id.txt'
# 0.12.0 shipped this name before the file was generalised; a bay staged then
# still has it.
$script:LEGACYASSETIDFILE = 'C:\Enrollment\measuringtool-id.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)
# Date-stamped at source. An append-only name can never age out of the
# GE-Enforce retention sweep, which drops by LastWriteTime - a file written
# every cycle is always "recent" and grows forever.
Join-Path $script:LOGDIR ('{0}-backup-{1}.log' -f $Kind, (Get-Date -Format 'yyyyMMdd'))
}
function Write-ShopdbBackupLog {
param([Parameter(Mandatory)][string]$Kind,
[Parameter(Mandatory)][string]$Message)
if (-not (Test-Path $script:LOGDIR)) {
New-Item -ItemType Directory -Path $script:LOGDIR -Force -EA SilentlyContinue | Out-Null
}
$line = '[{0}] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
Add-Content -Path (Get-ShopdbLogPath -Kind $Kind) -Value $line -EA SilentlyContinue
Write-Host $line
}
function Write-ShopdbQuietState {
<#
Log a no-change outcome once, then stay silent while it holds.
Returns nothing; the caller exits on a quiet outcome.
GE-Enforce runs every 5 minutes. An outcome that does not change is
worth saying once, not 288 times a day. Any real event clears the state
so the next quiet spell announces itself - otherwise a PC that stopped
working would look exactly like one still posting.
#>
param([Parameter(Mandatory)][string]$Kind,
[Parameter(Mandatory)][string]$State,
[Parameter(Mandatory)][string]$Message)
$statefile = Join-Path $script:LOGDIR ('{0}-backup.state' -f $Kind)
$previous = ''
if (Test-Path $statefile) {
try { $previous = (Get-Content -LiteralPath $statefile -First 1 -EA Stop).Trim() } catch { }
}
if ($previous -ne $State) {
Write-ShopdbBackupLog -Kind $Kind -Message $Message
Set-Content -Path $statefile -Value $State -EA SilentlyContinue
}
}
function Clear-ShopdbQuietState {
param([Parameter(Mandatory)][string]$Kind)
Remove-Item -LiteralPath (Join-Path $script:LOGDIR ('{0}-backup.state' -f $Kind)) `
-Force -EA SilentlyContinue
}
function Get-ShopdbRegValue {
<#
One value from HKLM:\SOFTWARE\GE\ShopDB.
The SAME contract the display kiosks use: Install-ShopdbKiosk writes
BaseUrl / ApiToken / CollectorKey there and ACLs it to SYSTEM and
Administrators. Machine bays read the same place rather than inventing
a second home for one secret, so one delivery mechanism serves the whole
fleet and the diagnostics collector already knows to redact it.
#>
param([Parameter(Mandatory)][string]$Name)
try {
$props = Get-ItemProperty -Path $script:SHOPDBREG -EA Stop
if ($props.PSObject.Properties.Name -contains $Name) {
$val = [string]$props.$Name
if ($val) { return $val.Trim() }
}
} catch { }
return ''
}
function Get-ShopdbCollectorKey {
# Registry first (how the fleet is provisioned), then the enrollment file
# for a bay staged before that existed.
$key = Get-ShopdbRegValue -Name 'CollectorKey'
if ($key) { return $key }
if (Test-Path $script:KEYFILE) {
foreach ($line in (Get-Content -LiteralPath $script:KEYFILE -EA SilentlyContinue)) {
if ($line -match '^\s*collector\s*=\s*(.+)$') { return $matches[1].Trim() }
}
}
return ''
}
function Get-ShopdbAssetId {
<#
The asset number of the device this PC drives, or ''.
Same file and same precedence the computers collector uses, so a bay
cannot report its marker under one identity and its backup under
another.
#>
foreach ($file in @($script:ASSETIDFILE, $script:LEGACYASSETIDFILE)) {
if (Test-Path $file) {
try {
$value = ([string](Get-Content -LiteralPath $file -First 1 -EA Stop)).Trim()
if ($value) { return $value }
} catch { }
}
}
return ''
}
function Get-ShopdbMachineNumber {
<#
The machine number this PC reports, from pc-config.txt.
A device-specific fallback can be supplied when the application itself
knows the number - NTLARS keeps one in its own registry key. Callers
without one just get '' and exit quietly.
#>
param([scriptblock]$Fallback)
if (Test-Path $script:PCCONFIG) {
foreach ($line in (Get-Content -LiteralPath $script:PCCONFIG -EA SilentlyContinue)) {
if ($line -match '^\s*machine(number|no)?\s*=\s*(.+)$') {
$val = $matches[2].Trim()
# 9999 is the imaging-time placeholder, not a real bay.
if ($val -and $val -ne '9999') { return $val }
}
}
}
if ($Fallback) {
try {
$val = & $Fallback
if ($val) { return ([string]$val).Trim() }
} catch { }
}
# NO asset-id.txt fallback here, deliberately. It belongs to the CALLER.
#
# A part-marker bay has no machine number of its own but does have the DNC
# registry tree, so Backup-NtlarsSettings.ps1 passes its key check and
# reaches this function. A shared fallback would make it post the machine's
# DNC settings under the MARKER's asset number - a kind that resolves by
# machine number, handed a device asset number - where today it exits
# quietly. That is a behaviour change to a script running in production.
#
# A kind that legitimately identifies itself by device passes
# -MachineNumberFallback { Get-ShopdbAssetId } instead. The per-kind
# mechanism already exists; this is what it is for.
return ''
}
function Get-ShopdbIntervalHours {
<#
Minimum hours between attempts, from the backups_intervalhours setting.
Read UNAUTHENTICATED from /api/settings/public, because this runs before
any credential is needed. That endpoint serves an allowlist, and the key
must be declared public by the plugin - it was not, for months, so this
read returned nothing and the fallback below silently governed the whole
fleet. The fallback stays (an unreachable server must not mean a hot
loop) but a miss is now LOGGED rather than swallowed, so the same
failure cannot hide again.
#>
param([Parameter(Mandatory)][string]$Kind,
[Parameter(Mandatory)][string]$BaseUrl,
[int]$Default = 24)
try {
$resp = Invoke-RestMethod -Uri "$BaseUrl/api/settings/public" -Method Get `
-TimeoutSec 10 -EA Stop
$val = $null
if ($resp -and $resp.PSObject.Properties.Name -contains 'data') {
if ($resp.data.PSObject.Properties.Name -contains 'backups_intervalhours') {
$val = $resp.data.backups_intervalhours
}
}
if ($val) { return [int]$val }
Write-ShopdbQuietState -Kind $Kind -State 'interval-not-public' -Message (
'backups_intervalhours is not readable from /api/settings/public; ' +
"using the built-in ${Default}h. The plugin must declare the key public.")
} catch {
Write-ShopdbQuietState -Kind $Kind -State 'interval-unreachable' -Message (
"Could not read settings from $BaseUrl ($($_.Exception.Message)); using ${Default}h.")
}
return $Default
}
function Initialize-ShopdbBackup {
<#
Every check that can say "nothing to do", in one call.
Returns a context object. .Proceed is $false when the run should stop -
the reason has already been logged, once. On $true the context carries
Kind, BaseUrl, CollectorKey, MachineNumber and IntervalHours, and the
marker has NOT yet been stamped (Send-ShopdbBackup does that).
#>
param([Parameter(Mandatory)][string]$Kind,
[string]$BaseUrl,
[scriptblock]$MachineNumberFallback,
[switch]$Force)
$ctx = [pscustomobject]@{
Kind = $Kind
Proceed = $false
BaseUrl = ''
CollectorKey = ''
MachineNumber = ''
IntervalHours = 24
}
$machineNumber = Get-ShopdbMachineNumber -Fallback $MachineNumberFallback
if (-not $machineNumber) {
Write-ShopdbQuietState -Kind $Kind -State 'no-machine-number' -Message (
'No machine number in pc-config.txt and no device fallback. ' +
'A backup cannot be filed against an asset - skipping.')
return $ctx
}
$collectorKey = Get-ShopdbCollectorKey
if (-not $collectorKey) {
Write-ShopdbQuietState -Kind $Kind -State 'no-collector-key' -Message (
"No collector key in $script:SHOPDBREG\CollectorKey or $script:KEYFILE. " +
'The collector endpoint has no IP-allowlist path, unlike the GE-Enforce ' +
'manifest fetch, so it always needs a collector-scoped token. Skipping.')
return $ctx
}
if (-not $BaseUrl) { $BaseUrl = Get-ShopdbRegValue -Name 'BaseUrl' }
if (-not $BaseUrl) {
Write-ShopdbQuietState -Kind $Kind -State 'no-base-url' -Message (
"No ShopDB BaseUrl in $script:SHOPDBREG and none passed. Skipping.")
return $ctx
}
$BaseUrl = $BaseUrl.TrimEnd('/')
$intervalHours = Get-ShopdbIntervalHours -Kind $Kind -BaseUrl $BaseUrl
$markerfile = Join-Path $script:LOGDIR ('{0}-backup.marker' -f $Kind)
if (-not $Force -and (Test-Path $markerfile)) {
try {
$last = (Get-Item $markerfile).LastWriteTime
if (((Get-Date) - $last).TotalHours -lt $intervalHours) {
$due = $last.AddHours($intervalHours).ToString('yyyy-MM-dd HH:mm')
Write-ShopdbQuietState -Kind $Kind -State ("throttled-$due") -Message (
"Throttled: posted within the last ${intervalHours}h, next attempt after $due. " +
'Use -Force to override.')
return $ctx
}
} catch { }
}
$ctx.Proceed = $true
$ctx.BaseUrl = $BaseUrl
$ctx.CollectorKey = $collectorKey
$ctx.MachineNumber = $machineNumber
$ctx.IntervalHours = $intervalHours
Clear-ShopdbQuietState -Kind $Kind
Write-ShopdbBackupLog -Kind $Kind -Message (
"=== $Kind backup start === machine $machineNumber, $BaseUrl, interval ${intervalHours}h")
return $ctx
}
function Send-ShopdbBackup {
<#
POST one captured config to the ShopDB collector.
-Bytes for a kind ShopDB parses and stores (storagebackend 'shopdb').
-ContentHash with -SharePath for a kind whose bytes stay on the share
(storagebackend 'share'), where ShopDB keeps metadata and a pointer.
Returns $true when ShopDB accepted it, whether that produced a new
revision or a no-op; an unchanged config is a no-op by design and is
the expected outcome most cycles.
#>
param([Parameter(Mandatory)][pscustomobject]$Context,
[byte[]]$Bytes,
[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
# Marker BEFORE the post, deliberately. If ShopDB is unreachable we do not
# want every cycle for the rest of the day retrying; the next window picks
# it up.
$markerfile = Join-Path $script:LOGDIR ('{0}-backup.marker' -f $kind)
Set-Content -Path $markerfile -Value (Get-Date -Format 'o') -EA SilentlyContinue
# sourcehostname is load-bearing now, not just informational: ShopDB
# resolves a part-marker PC's backup to ITS marker through this field, and
# dedup keys a revision chain on it. An empty value silently files the
# backup against the operation instead and merges two devices' histories,
# so fall back to the DNS name rather than posting a blank.
$sourcehost = $env:COMPUTERNAME
if (-not $sourcehost) {
try { $sourcehost = [System.Net.Dns]::GetHostName() } catch { $sourcehost = '' }
}
$payload = @{
machinenumber = $Context.MachineNumber
backupkind = $kind
sourcehostname = $sourcehost
collectedat = (Get-Date).ToUniversalTime().ToString('o')
}
if ($SourceFileName) { $payload['sourcefilename'] = $SourceFileName }
if ($Bytes) {
$payload['contentbase64'] = [Convert]::ToBase64String($Bytes)
$payload['bytesize'] = $Bytes.Length
}
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 }
Write-ShopdbBackupLog -Kind $kind -Message (
'Posting {0} for machine {1}' -f $size, $Context.MachineNumber)
try {
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$resp = Invoke-RestMethod -Uri $uri -Method Post `
-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) }
'noop' { Write-ShopdbBackupLog -Kind $kind -Message 'Settings unchanged - no new revision (expected most cycles).' }
default { Write-ShopdbBackupLog -Kind $kind -Message (
"ShopDB returned action '{0}'." -f $resp.data.action) }
}
foreach ($warning in @($resp.data.warnings)) {
if ($warning) { Write-ShopdbBackupLog -Kind $kind -Message " WARNING: $warning" }
}
return $true
} catch {
# A 400 here is usually meaningful rather than transient: an
# unconfigured device, or a machine number ShopDB does not know. Log the
# server's own message so the cause is visible at the bay.
$detail = $_.Exception.Message
try {
$stream = $_.Exception.Response.GetResponseStream()
$reader = New-Object IO.StreamReader($stream)
$body = $reader.ReadToEnd()
if ($body) { $detail = $body }
} catch { }
Write-ShopdbBackupLog -Kind $kind -Message "Post failed: $detail"
return $false
}
}
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-ShopdbLastBackupAction, Test-ShopdbBackupNoop,
Add-ShopdbBackupSkip, Get-ShopdbBackupSkips, Clear-ShopdbBackupSkips,
Get-ShopdbAssetId

View File

@@ -0,0 +1,562 @@
# 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()
# A distinguishable exception, not a bare string. The caller must be able to
# tell "this PC is not a marker bay" (quiet, correct, permanent) from "this
# marker bay is misconfigured" (loud, and someone needs to fix it). Filing
# both as the former is how a broken bay stops being backed up and nothing
# anywhere says so.
if (-not (Test-Path $script:MARKKEY)) {
throw [System.Management.Automation.ItemNotFoundException]::new(
"No marker configuration at $script:MARKKEY - this PC does not drive a Telesis marker.")
}
$props = Get-ItemProperty -Path $script:MARKKEY -ErrorAction Stop
# Every value is read through a presence check because this module runs
# under StrictMode Latest, where touching a registry value that is not
# there throws PropertyNotFoundException. Reading them directly made the
# "no Port Id" guard below unreachable, and turned a PARTIALLY configured
# marker bay into "no Telesis marker configured on this PC" - permanent,
# quiet, and the exact failure this feature exists to prevent.
$names = $props.PSObject.Properties.Name
function Read-MarkValue {
param([string]$Name, [string]$Default = '')
if ($names -contains $Name) {
$value = $props.$Name
if ($null -ne $value) { return ([string]$value).Trim() }
}
return $Default
}
$portid = Read-MarkValue 'Port Id'
if (-not $portid) { throw "No 'Port Id' under $script:MARKKEY - marker serial port is not configured." }
# Defaults match the fleet's own configuration (9600-8-N-1 in WJPRT.reg), so
# a key missing one value still produces a usable port rather than a zero
# baud rate that fails at Open with an unhelpful message.
# TryParse, not a cast, for the OUT-OF-RANGE case.
#
# An empty or non-numeric value was already handled: PowerShell coerces
# [int]'' to 0 (verified in the VM), so the -replace leaves '' and the
# zero-check below substitutes the default. What is NOT handled is a numeric
# string too large for Int32 - '99999999999999' throws on the cast, and the
# caller files that as "this PC is not a marker bay", so a real bay would
# stop being backed up with nothing saying why.
#
# Narrow, but the whole guard is here for garbage in a registry value, and
# leaving one shape of garbage fatal defeats the point.
[int]$baud = 0
if (-not [int]::TryParse(((Read-MarkValue 'Baud' '9600') -replace '\D', ''),
[ref]$baud) -or $baud -le 0) {
$baud = 9600
}
[int]$databits = 0
if (-not [int]::TryParse(((Read-MarkValue 'Data Bits' '8') -replace '\D', ''),
[ref]$databits) -or $databits -le 0) {
$databits = 8
}
[pscustomobject]@{
PortName = $portid
BaudRate = $baud
Parity = switch -Regex (Read-MarkValue 'Parity' 'None') {
'^(?i)odd' { 'Odd' }
'^(?i)even' { 'Even' }
default { 'None' }
}
DataBits = $databits
StopBits = if ((Read-MarkValue 'Stop Bits' '1') -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'."
}
$reply = $text.Substring($start + 1, $end - $start - 1)
# Check the reply's own BCC, not just the data blocks'. A corrupted byte in
# a LISTING entry would otherwise be trusted, and the wrong filename would
# be requested and stored under a name nothing on the controller has.
$tail = $text.Substring($end + 1)
if ($tail.Length -ge 3) {
$expected = New-TelesisBcc -Type $Type -Data ([Text.Encoding]::ASCII.GetBytes($reply))
if ($tail.Substring(0, 3) -ne $expected) {
throw ("Bad checksum on the reply to command '{0}' '{1}' (got {2}, expected {3})." -f
$Type, $Data, $tail.Substring(0, 3), $expected)
}
}
return $reply
}
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' } }
}
# BOUNDED, three ways. The receiver drives this loop, so a controller that
# keeps answering - repeating a name, or answering noise on a marginal cable
# - would spin here for ever. That is not a hang in a background job: this
# holds the MARKING port, and GE-Enforce waits on the script with no
# timeout, so one chatty controller wedges the whole enforcement cycle and
# the operator cannot mark a part.
#
# A truncated listing is a bad outcome too, so both limits are far above any
# real controller (a full bay carries tens of patterns, and a listing takes
# well under a second per name) and both THROW rather than returning a short
# list quietly. A partial snapshot presented as complete is the failure this
# whole feature exists to avoid.
$MAXNAMES = 2000
$BUDGETSECONDS = 90
$names = New-Object System.Collections.Generic.List[string]
$seen = New-Object System.Collections.Generic.HashSet[string]
$clock = [System.Diagnostics.Stopwatch]::StartNew()
$name = Invoke-TelesisCommand -Port $Port -Type $spec.Type -Data $spec.Start
while ($name) {
if (-not $seen.Add($name)) {
throw ("$Class listing repeated '$name' after $($names.Count) entries - " +
'the controller is looping rather than ending the list.')
}
$names.Add($name)
if ($names.Count -ge $MAXNAMES) {
throw ("$Class listing exceeded $MAXNAMES entries - refusing to keep " +
'the marking port while the controller talks indefinitely.')
}
if ($clock.Elapsed.TotalSeconds -ge $BUDGETSECONDS) {
throw ("$Class listing exceeded $BUDGETSECONDS seconds after " +
"$($names.Count) entries - releasing the marking port.")
}
$name = Invoke-TelesisCommand -Port $Port -Type $spec.Type -Data $spec.Step
}
$clock.Stop()
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)
# The comma is load-bearing. A bare `return $data` is ENUMERATED by the
# pipeline: the caller gets Object[] of boxed bytes for a normal file, a
# scalar for a one-byte file, and $null for an EMPTY one. A null then
# reaches ComputeHash in the manifest builder, which throws, and under
# ErrorActionPreference=Continue the assignment is skipped so the entry
# silently keeps the PREVIOUS file's hash - a corrupted manifest stored as
# a good revision. Empty parameter files are entirely plausible.
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 = [int][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

View File

@@ -0,0 +1,203 @@
# Backup-NtlarsSettings.ps1
#
# Captures this PC's LIVE NTLARS/DNC registry settings and posts them to ShopDB,
# which files them against the MACHINE (not this PC) and keeps a revision
# history. A tech can then re-download the .reg from the machine's page instead
# of hunting for a per-machine file on the share.
#
# 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 WOW6432Node IS EXPLICIT:
# NTLARS is a 32-bit app, so its settings physically live under
# HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC. GE-Enforce runs this
# script in 64-bit PowerShell, where HKLM:\SOFTWARE\GE Aircraft Engines\DNC
# is a DIFFERENT (usually absent) key. Reading the unredirected path would
# find nothing and back up an empty config - silently, every cycle. The path
# below is therefore spelled out and never abbreviated.
#
# Note the asymmetry with what NTLARS itself writes: its Save... button
# exports WITHOUT the WOW6432Node segment. ShopDB accepts either dialect and
# stores a dialect-neutral projection, so this script does not need to care.
#
# THROTTLE, LOGGING, CREDENTIALS:
# All handled by common\lib\ShopdbBackupClient.psm1, which every backup kind
# shares. GE-Enforce fires this every cycle, so the module holds the marker
# file, exits early until backups_intervalhours has elapsed, and logs a
# no-change outcome ONCE rather than every five minutes. The interval comes
# from ShopDB, so cadence is changed centrally rather than by editing this
# file on the share.
#
# Always exits 0 so the GE-Enforce "last run result" stays clean. Failures are
# logged, never thrown.
param(
[string]$BaseUrl = 'https://tsgwp00525.wjs.geaerospace.net/shopdb',
[int]$TimeoutSec = 30,
# Force a post regardless of the throttle. For a tech capturing a
# known-good config on demand.
[switch]$Force
)
$ErrorActionPreference = 'Continue'
# The only path this script still owns. Log, marker, state, collector key,
# enrollment and base URL all moved to ShopdbBackupClient, which names its files
# per KIND so two backup kinds on one bay cannot collide.
$DNCKEY = 'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC'
function Convert-RegTypeName {
param($Kind)
switch ("$Kind") {
'String' { 'REG_SZ' }
'ExpandString' { 'REG_EXPAND_SZ' }
'DWord' { 'REG_DWORD' }
'QWord' { 'REG_QWORD' }
'MultiString' { 'REG_MULTI_SZ' }
'Binary' { 'REG_BINARY' }
default { 'REG_SZ' }
}
}
function Get-DncKeys {
<#
The DNC key and every subkey, root first. Split out from
Export-DncToReg so the formatting logic can be exercised against mock
keys on a machine with no registry (see Test-RegExport.ps1).
#>
$keys = @(Get-Item -Path $DNCKEY -EA Stop)
$keys += @(Get-ChildItem -Path $DNCKEY -Recurse -EA SilentlyContinue)
return $keys
}
function Export-DncToReg {
<#
Emits .reg text in the WOW6432Node dialect for the supplied keys.
Built by hand rather than shelling out to `reg export` because reg.exe
writes UTF-16 to a temp file we would then have to read back, and
because this keeps the value types explicit instead of reparsing them.
Takes the key list as a parameter so it can be tested with mocks; the
escaping and dword formatting here are the part that would corrupt a
backup silently and only surface at restore time.
#>
param([Parameter(Mandatory)]$Keys)
$lines = @('Windows Registry Editor Version 5.00', '')
$lines += "; NTLARS DNC Registry Backup"
$lines += "; Computer: $env:COMPUTERNAME"
$lines += "; Date: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
$lines += ''
foreach ($key in $Keys) {
# PSPath -> the literal HKEY_LOCAL_MACHINE\... form a .reg file needs.
$path = $key.Name -replace '^HKEY_LOCAL_MACHINE', 'HKEY_LOCAL_MACHINE'
$lines += "[$path]"
foreach ($name in $key.GetValueNames()) {
$kind = Convert-RegTypeName $key.GetValueKind($name)
$data = $key.GetValue($name)
$lhs = if ($name -eq '') { '@' } else {
# .NET replacement strings do NOT process backslash escapes, so
# the replacement is the literal output: '\\' emits two
# backslashes, which is what .reg escaping wants. Writing
# '\\\\' here emits FOUR and silently corrupts every path-valued
# setting - verified on Windows before this was fixed.
'"{0}"' -f ($name -replace '\\', '\\' -replace '"', '\"')
}
switch ($kind) {
'REG_DWORD' {
$lines += ('{0}=dword:{1:x8}' -f $lhs, [uint32]$data)
}
'REG_BINARY' {
$hex = ($data | ForEach-Object { '{0:x2}' -f $_ }) -join ','
$lines += ('{0}=hex:{1}' -f $lhs, $hex)
}
'REG_QWORD' {
$bytes = [BitConverter]::GetBytes([uint64]$data)
$hex = ($bytes | ForEach-Object { '{0:x2}' -f $_ }) -join ','
$lines += ('{0}=hex(b):{1}' -f $lhs, $hex)
}
'REG_MULTI_SZ' {
$joined = (($data -join "`0") + "`0`0")
$bytes = [Text.Encoding]::Unicode.GetBytes($joined)
$hex = ($bytes | ForEach-Object { '{0:x2}' -f $_ }) -join ','
$lines += ('{0}=hex(7):{1}' -f $lhs, $hex)
}
'REG_EXPAND_SZ' {
$bytes = [Text.Encoding]::Unicode.GetBytes(("$data" + "`0"))
$hex = ($bytes | ForEach-Object { '{0:x2}' -f $_ }) -join ','
$lines += ('{0}=hex(2):{1}' -f $lhs, $hex)
}
default {
$escaped = ("$data" -replace '\\', '\\' -replace '"', '\"')
$lines += ('{0}="{1}"' -f $lhs, $escaped)
}
}
}
$lines += ''
}
return ($lines -join "`r`n") + "`r`n"
}
# =============================================================================
# Main
#
# Guarded so the file can be DOT-SOURCED to get the functions without running a
# backup. Test-RegExport.ps1 relies on this to exercise Export-DncToReg against
# mock keys on a machine with no registry.
# =============================================================================
if ($MyInvocation.InvocationName -eq '.') { return }
# The plumbing - key lookup, base URL, interval, throttle, logging, the POST and
# its response handling - lives in ShopdbBackupClient. It used to live here, and
# being the only implementation meant every defect in it was invisible: the
# interval read fell back to a hardcoded 24h for months because nobody had a
# second copy to compare against, and the log wrote three lines every five
# minutes whatever happened. Seven other Backup-*.ps1 scripts on this share
# capture device configs and post none of them; they can now do so without
# inheriting a copy of all that.
#
# What stays here is the part that is actually about NTLARS: where its registry
# lives, and how to turn it into .reg text.
Import-Module (Join-Path $PSScriptRoot '..\lib\ShopdbBackupClient.psm1') -Force
if (-not (Test-Path $DNCKEY)) {
# Not an error: plenty of PC types have no NTLARS at all. On those PCs this
# is the permanent state, so it is said once rather than 288 times a day.
Write-ShopdbQuietState -Kind 'ntlars' -State 'no-dnc-key' -Message (
"No DNC key at $DNCKEY - NTLARS is not installed on this PC. Nothing to do.")
exit 0
}
# NTLARS keeps its own MachineNo, used only when pc-config.txt has none. ShopDB
# compares the two and warns on a mismatch rather than silently trusting one.
$ctx = Initialize-ShopdbBackup -Kind 'ntlars' -Force:$Force `
-BaseUrl $(if ($PSBoundParameters.ContainsKey('BaseUrl')) { $BaseUrl } else { '' }) `
-MachineNumberFallback {
try {
$general = Get-ItemProperty -Path (Join-Path $DNCKEY 'General') -EA Stop
if ($general.MachineNo) { return ([string]$general.MachineNo).Trim() }
} catch { }
return ''
}
if (-not $ctx.Proceed) { exit 0 }
try {
$regText = Export-DncToReg -Keys (Get-DncKeys)
} catch {
Write-ShopdbBackupLog -Kind 'ntlars' -Message "Failed to read the DNC key: $_"
exit 0
}
# UTF-16LE + BOM, matching what regedit and NTLARS emit. ShopDB sniffs the BOM,
# so this is belt-and-braces rather than strictly required.
$bytes = [byte[]](0xFF, 0xFE) + [Text.Encoding]::Unicode.GetBytes($regText)
[void](Send-ShopdbBackup -Context $ctx -Bytes $bytes `
-SourceFileName ("{0}.reg" -f $ctx.MachineNumber) -TimeoutSec $TimeoutSec)
Write-ShopdbBackupLog -Kind 'ntlars' -Message '=== Backup-NtlarsSettings end ==='
exit 0

View File

@@ -0,0 +1,583 @@
# 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 delete again on
# a no-op. That way a failed post leaves a real snapshot on the share 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.
#
# A snapshot whose POST failed is deliberately KEPT, and nothing tidies it
# later. It cannot safely be deleted here: a client-side timeout does not mean
# the server rejected it, so the revision may exist and point at this very
# directory. An orphan directory costs a few hundred kilobytes; deleting a
# snapshot ShopDB has recorded costs the backup itself.
#
# 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.
# CmdletBinding so an unknown argument is an ERROR, not a shrug. Without it a
# script's param block silently drops unrecognised named arguments into $args -
# so a typo in the manifest entry's Args ("-WindowStartMinutes 45") would be
# ignored and the capture would quietly run at the wrong time, with nothing in
# the log to say why. Cost me an hour of debugging a gate that was fine.
[CmdletBinding()]
param(
[string]$BaseUrl = '',
# Root of the opaque-backup tree. The snapshot lands in
# <ShareRoot>\partmarker\<marker asset>\<yyyyMMdd-HHmmss>\.
#
# EMPTY BY DEFAULT, and derived at runtime from where this script is running
# (see below). Passing a value overrides that, which is what a bench test
# wants.
#
# WHY NOT HARDCODE THE UNC: GE-Enforce mounts the share as W: using SFLD
# credentials from HKLM\SOFTWARE\GE\SFLD\Credentials, runs manifest
# entries from W:\<pctype>\, and deletes the mapping at the end of the
# cycle. Windows keys SMB sessions by the SERVER NAME STRING, so reaching
# the same server by a different name than the one net use mapped can force
# a second authentication - and where the credentials differ it fails
# outright with error 1219, "multiple connections to a server by the same
# user are not allowed". A path that is obviously the same share fails on
# the name form alone, and the error is nothing like the cause.
[string]$ShareRoot = '',
[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),
# Minute the window OPENS within those hours. 0 means the whole hour.
# Set from the manifest entry's Args when the first run of the day should
# land at a particular time - the shift change is a moment, not an hour,
# and a capture is easiest to watch when somebody expects it.
[ValidateRange(0, 59)]
[int]$WindowStartMinute = 0,
# 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
}
}
function ConvertTo-UncPath {
<#
.SYNOPSIS
The UNC equivalent of a path on a mapped drive, or the path unchanged.
.DESCRIPTION
We WRITE through the drive letter GE-Enforce mapped, because that is the
connection it authenticated - reaching the same server by another name
can cost a second authentication or fail outright with error 1219.
But the path RECORDED in ShopDB has to be one a human can use. "W:" is a
mapping that exists only inside the SYSTEM session, only while the cycle
runs, and only on that one PC. Storing it means a tech opening the
marker's page gets a path that resolves to nothing anywhere.
So: write by drive, record by UNC. DisplayRoot on a mapped PSDrive is
the share it points at; a local drive has none, and a path that is
already UNC needs no help - both fall through unchanged.
#>
param([Parameter(Mandatory)][string]$Path)
if ($Path -like '\\*') { return $Path }
$qualifier = ''
try { $qualifier = Split-Path -Qualifier $Path } catch { return $Path }
if (-not $qualifier) { return $Path }
$letter = $qualifier.TrimEnd(':')
$rest = Split-Path -NoQualifier $Path
# Three ways a drive can tell you where it points, and which one is
# populated depends on how it was created:
# DisplayRoot set by `net use` mappings - what GE-Enforce makes
# Root set by New-PSDrive to a UNC, where DisplayRoot is EMPTY
# ProviderName the WMI view, as a backstop if the PSDrive lookup is odd
try {
$drive = Get-PSDrive -Name $letter -ErrorAction Stop
foreach ($candidate in @($drive.DisplayRoot, $drive.Root)) {
if ($candidate -and $candidate -like '\\*') {
return ($candidate.TrimEnd('\') + $rest)
}
}
} catch { }
try {
$disk = Get-CimInstance -ClassName Win32_LogicalDisk `
-Filter ("DeviceID='{0}:'" -f $letter) -ErrorAction Stop
if ($disk.ProviderName -and $disk.ProviderName -like '\\*') {
return ($disk.ProviderName.TrimEnd('\') + $rest)
}
} catch { }
return $Path
}
# =============================================================================
# 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
# Both the hour AND the minute, so a window can be "14:45 onwards" rather
# than the whole of the 14:00 hour. GE-Enforce fires every 5 minutes, so
# the capture starts on the first cycle at or after that time and the 24h
# throttle stops it running again that day.
$inwindow = ($WindowHours -contains $now.Hour) -and ($now.Minute -ge $WindowStartMinute)
# 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) -and
($now.Minute -ge $WindowStartMinute)
}
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 {
# State is the CONDITION, not the clock. Embedding the hour made
# it "new" every hour, so the say-once module wrote the same line
# ~20 times a day per bay - a small rerun of the 3,234-line log it
# was built to stop.
Write-ShopdbQuietState -Kind $KIND -State 'outside-window' -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}:{1:00}' -f $_, $WindowStartMinute }) -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 [System.Management.Automation.ItemNotFoundException] {
# The registry key is absent: this PC is not a part-marker bay. Quiet and
# permanent, which is correct - most of the fleet lands here every cycle.
Write-ShopdbQuietState -Kind $KIND -State 'no-marker-config' -Message (
"No Telesis marker configured on this PC ($_). Nothing to do.")
exit 0
} catch {
# The key EXISTS and reading it failed: a marker bay that is misconfigured,
# or a registry permission problem. Filing this as "not a marker bay" - as
# this used to - meant a half-configured bay was never backed up again and
# nothing anywhere said so. It is the silent-failure shape this whole
# feature exists to avoid, so it is logged every cycle and counted as a
# skip, not swallowed.
$message = "Marker configuration present but unreadable: $_"
Write-ShopdbBackupLog -Kind $KIND -Message $message
Add-ShopdbBackupSkip -Kind $KIND -Reason 'marker-config-unreadable'
exit 1
}
# -MachineNumberFallback, not a shared default: a marker bay legitimately has
# no machine number - the DEVICE is what the backup belongs to, and
# asset-id.txt is what names it. Putting this in the shared lookup would change
# what the NTLARS backup reports on these same bays, which run eDNC too.
#
# Safe on the wire: the part-marker kind resolves the reporting PC to its own
# marker by hostname first, and falls back to matching this value against
# assets.assetnumber - which is exactly what asset-id.txt holds.
$ctx = Initialize-ShopdbBackup -Kind $KIND -Force:$Force `
-BaseUrl $(if ($BaseUrl) { $BaseUrl } else { '' }) `
-MachineNumberFallback { Get-ShopdbAssetId }
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() }
}
# $null means the capture broke partway. An EMPTY hashtable means the capture
# worked and the controller genuinely holds nothing - a factory-reset or
# just-replaced unit. Treating those the same made a wiped controller log a
# capture failure every window for ever, and escalate to the loud warning, when
# in fact the backup was working perfectly and reporting the truth.
if ($null -eq $files) {
# 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 ----------------------------------------
# Derive the share root from THIS SCRIPT'S OWN LOCATION unless told otherwise.
# Running as a manifest entry, $PSScriptRoot is on the share GE-Enforce just
# authenticated (W:\<pctype>\scripts), so its qualifier is a handle to that
# exact connection - same server name, same credentials, already established.
# Nothing else is guaranteed to be.
if (-not $ShareRoot) {
$qualifier = Split-Path -Qualifier $PSScriptRoot -ErrorAction SilentlyContinue
if ($qualifier) {
$ShareRoot = Join-Path $qualifier 'backup'
Write-ShopdbBackupLog -Kind $KIND -Message (
'Share root derived from this script location: {0}' -f $ShareRoot)
} else {
# Running from a UNC path directly (no drive letter), which happens if
# the entry is invoked outside the usual mapping. Use the script's own
# server and share rather than a hardcoded hostname, for the same
# session-reuse reason.
$parts = $PSScriptRoot -split '\\' | Where-Object { $_ }
if ($parts.Count -ge 2) {
$ShareRoot = '\\{0}\{1}\backup' -f $parts[0], $parts[1]
Write-ShopdbBackupLog -Kind $KIND -Message (
'Share root derived from this script UNC: {0}' -f $ShareRoot)
} else {
Write-ShopdbBackupLog -Kind $KIND -Message (
'Cannot derive a share root from {0} and none was passed - skipping.' -f $PSScriptRoot)
Add-ShopdbBackupSkip -Kind $KIND -Reason 'no share root'
exit 0
}
}
}
$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 {
# Counted like the others: a snapshot captured but not stored is just as
# missed as one never captured, and share credentials failing on a bay is
# exactly the kind of quiet breakage this count exists to surface.
Add-ShopdbBackupSkip -Kind $KIND -Reason 'could not write to the share'
Write-ShopdbBackupLog -Kind $KIND -Message "Could not write $snapshotdir : $_"
# Name the likely cause, because the symptom lies. This runs as SYSTEM,
# which authenticates to the network as the MACHINE account, and a SYSTEM
# process reaching a UNC path with no credentials gets an access-denied
# that Test-Path reports as "not found" - so a permissions problem looks
# like a missing directory and sends the next person hunting the wrong
# thing entirely.
#
# The share is mounted only during the GE-Enforce cycle, and all SYSTEM
# processes share one logon session, so a manifest entry inherits that
# connection. A scheduled task would not - it would run outside the cycle
# and find nothing there.
if (-not (Test-Path -LiteralPath $ShareRoot)) {
Write-ShopdbBackupLog -Kind $KIND -Message (
"$ShareRoot is not reachable from this process. Running as SYSTEM, " +
'so this is usually the machine account lacking rights on the share ' +
'rather than a missing path - Test-Path cannot tell the two apart. ' +
'Confirm this runs as a GE-Enforce manifest entry (the share is ' +
'mounted only during the cycle) and that the ACL admits computer accounts.')
}
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
}
# Written through $snapshotdir (the mapped drive), recorded as UNC so the path
# stored against the marker means something on any PC, at any time.
$reportedpath = ConvertTo-UncPath -Path $snapshotdir
if ($reportedpath -ne $snapshotdir) {
Write-ShopdbBackupLog -Kind $KIND -Message (
'Recording {0} (written through {1})' -f $reportedpath, $snapshotdir)
}
$posted = Send-ShopdbBackup -Context $ctx `
-ContentHash $manifest.ManifestHash -SharePath $reportedpath `
-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

View File

@@ -0,0 +1,33 @@
@echo off
REM Install-AcroReader.cmd - Install Adobe Acrobat Reader DC + DC update patch
REM
REM Two-step install: base MSI with enterprise transform, then DC update patch.
REM Script lives in <type>/scripts/; MSI + MST + MSP + CAB live in sibling
REM <type>/apps/. pushd into apps\ so the MSI's Media-table CAB reference
REM (Data1.cab) resolves against its sibling, and msiexec doesn't choke on
REM a mapped-drive path with ..\ normalization (was returning 1619).
setlocal
pushd "%~dp0..\apps"
echo Installing Adobe Acrobat Reader DC...
msiexec /i "AcroRead.msi" TRANSFORMS="AcroRead.mst" /quiet /norestart
set RC=%errorlevel%
if %RC% neq 0 if %RC% neq 3010 (
echo Acrobat Reader MSI failed with exit code %RC%
popd
exit /b %RC%
)
echo Applying Adobe Reader DC update patch...
msiexec /p "AcroRdrDCUpd2500120531.msp" /quiet /norestart
set RC=%errorlevel%
if %RC% neq 0 if %RC% neq 3010 (
echo Acrobat Reader patch failed with exit code %RC%
popd
exit /b %RC%
)
popd
echo Adobe Acrobat Reader DC installed successfully.
exit /b 0

View File

@@ -0,0 +1,75 @@
@echo off
REM Install-Oracle11r2.cmd
REM Expands the GE Oracle Client 11.2 Administrator zip to a temp dir and
REM runs Oracle Universal Installer silently with the GE-customized
REM response file.
REM
REM Expected layout on the SFLD share (relative to this .cmd):
REM ..\apps\Oracle_OracleDatabase_11r2_V03.zip (686 MB)
REM
REM Called by Install-FromManifest.ps1 (Type=CMD). Exit codes surface back
REM to the enforcer.
REM
REM Oracle 11.2 OUI exit codes worth knowing:
REM 0 = success
REM 3 = success but with warnings
REM 1 = general failure
REM 6 = silent install requested but missing / bad response file
setlocal enabledelayedexpansion
set "LOG=C:\Logs\OracleClient\install.log"
if not exist "C:\Logs\OracleClient" mkdir "C:\Logs\OracleClient"
REM Emit a datestamp
for /f "tokens=2 delims==" %%I in ('wmic os get localdatetime /value 2^>nul ^| find "="') do set LDT=%%I
set "STAMP=!LDT:~0,14!"
echo [%STAMP%] Install-Oracle11r2.cmd starting >> "%LOG%"
set "SRC_ZIP=%~dp0..\apps\Oracle_OracleDatabase_11r2_V03.zip"
set "STAGING=%TEMP%\oracle-11r2-install"
set "CLIENT_DIR=%STAGING%\Oracle_OracleDatabase_11r2_V03\client"
set "RSP=%CLIENT_DIR%\response\ge_client_install.rsp"
if not exist "%SRC_ZIP%" (
echo [%STAMP%] ERROR: zip not found at %SRC_ZIP% >> "%LOG%"
echo ERROR: zip not found at %SRC_ZIP%
exit /b 2
)
echo [%STAMP%] Expanding %SRC_ZIP% to %STAGING% >> "%LOG%"
if exist "%STAGING%" rmdir /s /q "%STAGING%" >nul 2>&1
mkdir "%STAGING%"
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ^
"try { Expand-Archive -Path '%SRC_ZIP%' -DestinationPath '%STAGING%' -Force -ErrorAction Stop; exit 0 } catch { Write-Error $_; exit 1 }" ^
>> "%LOG%" 2>&1
if not exist "%CLIENT_DIR%\setup.exe" (
echo [%STAMP%] ERROR: expanded setup.exe not found at %CLIENT_DIR%\setup.exe >> "%LOG%"
exit /b 3
)
if not exist "%RSP%" (
echo [%STAMP%] ERROR: response file missing at %RSP% >> "%LOG%"
exit /b 4
)
echo [%STAMP%] Running OUI silent install (this takes 2-8 minutes) >> "%LOG%"
"%CLIENT_DIR%\setup.exe" -silent -waitforcompletion -nowait ^
-ignoreSysPrereqs ^
-responseFile "%RSP%" >> "%LOG%" 2>&1
set RC=%ERRORLEVEL%
echo [%STAMP%] OUI exit code: %RC% >> "%LOG%"
REM Cleanup staging dir to reclaim ~1.5 GB - OUI copies everything to ORACLE_HOME
echo [%STAMP%] Cleaning up staging dir >> "%LOG%"
rmdir /s /q "%STAGING%" >nul 2>&1
REM OUI returns 0 for success, 3 for success-with-warnings. Treat both as OK.
if %RC%==3 (
echo [%STAMP%] OUI reported warnings but install succeeded - returning 0 >> "%LOG%"
exit /b 0
)
exit /b %RC%

View File

@@ -0,0 +1,62 @@
# Migrate-PCType.ps1 - One-shot in-place rename of legacy pc-type.txt
# values to the gea-shopfloor-* taxonomy.
#
# Idempotent + safe: no-op if pc-type.txt already starts with
# 'gea-shopfloor-'. Decides collections vs nocollections from UDC's
# Uninstall reg presence (collections has UDC, nocollections doesn't).
# Standard-Timeclock + Lab map to gea-shopfloor-common.
#
# Runs every cycle (DetectionMethod=Always in manifest). Cheap because
# the no-op fast path is just one Get-Content + StartsWith check.
$ErrorActionPreference = 'Continue'
$typeFile = 'C:\Enrollment\pc-type.txt'
$subTypeFile = 'C:\Enrollment\pc-subtype.txt'
if (-not (Test-Path $typeFile)) { exit 0 }
$current = (Get-Content -LiteralPath $typeFile -First 1 -ErrorAction SilentlyContinue).Trim()
if (-not $current) { exit 0 }
# Already on new taxonomy
if ($current.StartsWith('gea-shopfloor-')) { exit 0 }
$subType = ''
if (Test-Path $subTypeFile) {
$subType = (Get-Content -LiteralPath $subTypeFile -First 1 -ErrorAction SilentlyContinue).Trim()
}
# Map legacy -> new
$newType = $null
switch -Regex ($current) {
'^Standard$' {
if ($subType -ieq 'Machine') {
$udcReg = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\UDC'
$newType = if ($udcReg) { 'gea-shopfloor-collections' } else { 'gea-shopfloor-nocollections' }
} elseif ($subType -ieq 'Timeclock') {
$newType = 'gea-shopfloor-common'
} else {
# Standard with no subtype - default to collections (most common)
$newType = 'gea-shopfloor-collections'
}
}
'^CMM$' { $newType = 'gea-shopfloor-cmm' }
'^Keyence$' { $newType = 'gea-shopfloor-keyence' }
'^Lab$' { $newType = 'gea-shopfloor-common' }
'^WaxAndTrace$' { $newType = 'gea-shopfloor-waxtrace' }
'^Genspect$' { $newType = 'gea-shopfloor-genspect' }
'^Display$' { $newType = 'gea-shopfloor-display' }
'^Heattreat$' { $newType = 'gea-shopfloor-heattreat' }
default { Write-Host "Migrate-PCType: unmapped legacy value '$current' - leaving alone"; exit 0 }
}
Write-Host "Migrate-PCType: '$current' (subType='$subType') -> '$newType'"
Set-Content -LiteralPath $typeFile -Value $newType -Encoding ascii -ErrorAction Stop
# Drop pc-subtype.txt - new taxonomy is single-string
if (Test-Path $subTypeFile) {
try { Remove-Item -LiteralPath $subTypeFile -Force -ErrorAction Stop; Write-Host " removed pc-subtype.txt" } catch {}
}
exit 0

View File

@@ -0,0 +1,186 @@
# Repair-AppShortcuts.ps1
#
# Repoint every shortcut for one app after its install location moves.
#
# WHY THIS EXISTS
#
# An MSI major upgrade fixes only the shortcuts the MSI itself owns. The
# copies we place - the Shopfloor Tools folder, and above all the per-user
# taskbar pin - keep pointing at the old install and the pin dies. Shopfloor
# bays are locked down so an operator cannot right-click and unpin it, and
# 07-TaskbarLayout cannot help: LayoutModification.xml is read only on the
# FIRST logon of a NEW profile. Existing profiles never re-read it.
#
# THE TRAP THIS SCRIPT EXISTS TO AVOID (verified in the win11 VM, 2026-09-04)
#
# The Defect Tracker shortcuts are MSI ADVERTISED (Darwin) shortcuts. Their
# Shortcut table Target is a Feature name, not a path, so Windows resolves
# them through the installer at click time. Three consequences:
#
# 1. WScript.Shell reports their TargetPath as
# C:\Windows\Installer\{ProductCode}\<stub>.exe, not the real exe.
# 2. Setting .TargetPath and calling .Save() REPORTS SUCCESS AND CHANGES
# NOTHING. WorkingDirectory and IconLocation do change, so the write
# looks like it worked. This is why the shortcut cannot be repaired
# in place, and why every write here is read back and verified.
# 3. A major upgrade issues a NEW ProductCode. An advertised shortcut
# copied out to a pin still names the OLD ProductCode, which no longer
# exists, so the pin cannot resolve at all. That, not the folder move
# on its own, is what kills a pinned copy.
#
# The fix for an advertised copy is to DELETE it and write a fresh plain
# shortcut to the real exe. Verified to work and to launch.
#
# WHAT IT LEAVES ALONE, ON PURPOSE
#
# The two locations the MSI owns (All Users Start Menu, Public Desktop) keep
# their advertised shortcuts when they are still advertised. Those resolve
# through MSI and self-repair, and converting them to plain shortcuts would
# throw that away and make the next move need this script again. The MSI
# upgrade is what fixes those. We only rewrite a copy the MSI does not
# manage, or an MSI-owned shortcut that has already been flattened to a
# plain stale path. -Force overrides.
#
# It does not restart Explorer. A replaced pin resolves on the user's next
# logon and shopfloor bays log off nightly. Killing Explorer mid-shift to
# save one logon is not a trade worth making.
#
# Usage (Type=PS1 manifest entry, DetectionMethod=Always):
# powershell -ExecutionPolicy Bypass -File Repair-AppShortcuts.ps1
# -ShortcutName Defect_Tracker
# -ExePath 'C:\Program Files (x86)\WJF_Defect_Tracker\Defect_Tracker.exe'
# -StalePathPattern 'WJF_Defect_Tracker'
param(
[Parameter(Mandatory=$true)][string]$ShortcutName,
[Parameter(Mandatory=$true)][string]$ExePath,
# Any shortcut whose stored target matches this is claimed even if its
# filename does not match ShortcutName. Catches a pin someone renamed.
[string]$StalePathPattern,
# Rewrite MSI-owned advertised shortcuts too, losing MSI self-repair.
[switch]$Force,
[switch]$WhatIfOnly
)
$ErrorActionPreference = 'Continue'
$logDir = 'C:\Logs\Shopfloor'
New-Item -ItemType Directory -Path $logDir -Force -EA SilentlyContinue | Out-Null
$log = Join-Path $logDir ('repair-shortcuts-{0}.log' -f (Get-Date -Format 'yyyyMMdd'))
function Log($m){ "$([DateTime]::Now.ToString('s')) $m" | Tee-Object -FilePath $log -Append | Out-Null }
Log "=== Repair shortcuts: $ShortcutName -> $ExePath ==="
# Nothing to point at means the app is not on this bay, or the manifest ran us
# before the MSI. Rewriting to a path that does not exist turns a stale
# shortcut into a broken one, so leave it and let the next cycle retry.
if (-not (Test-Path -LiteralPath $ExePath -PathType Leaf)) {
Log "SKIP: target does not exist: $ExePath"
exit 0
}
$workDir = Split-Path -Parent $ExePath
$leaf = "$ShortcutName.lnk"
# An advertised shortcut always reports its target inside the installer cache.
$AdvertisedTarget = '^[A-Za-z]:\\Windows\\Installer\\\{'
# Locations the MSI creates and maintains itself.
$msiOwnedDirs = @(
'C:\ProgramData\Microsoft\Windows\Start Menu\Programs'
'C:\Users\Public\Desktop'
)
# Copies we place. The MSI does not know about these and will never fix them.
$ourDirs = @(
'C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Shopfloor Tools'
'C:\Users\Public\Desktop\Shopfloor Tools'
)
foreach ($profileDir in (Get-ChildItem 'C:\Users' -Directory -EA SilentlyContinue)) {
$quick = Join-Path $profileDir.FullName 'AppData\Roaming\Microsoft\Internet Explorer\Quick Launch\User Pinned'
$ourDirs += (Join-Path $quick 'TaskBar')
$ourDirs += (Join-Path $quick 'StartMenu')
}
$wsh = New-Object -ComObject WScript.Shell
$checked = 0; $fixed = 0; $failed = 0; $skipped = 0
function Get-Target([string]$Path) {
try { return $wsh.CreateShortcut($Path).TargetPath } catch { return $null }
}
function Repair-Lnk([string]$Path, [bool]$MsiOwned) {
$script:checked++
$current = Get-Target $Path
if ($null -eq $current) {
Log " READ FAIL $Path"
$script:failed++
return
}
$advertised = $current -match $AdvertisedTarget
if ($current -eq $ExePath) { Log " ok $Path"; return }
# An MSI-owned shortcut that is still advertised resolves through the
# installer and the upgrade will correct it. Flattening it would cost us
# self-repair for no gain.
if ($advertised -and $MsiOwned -and -not $Force) {
Log " skip (MSI-owned, still advertised, upgrade will fix) $Path"
$script:skipped++
return
}
if ($WhatIfOnly) {
Log " WOULD FIX $Path : '$current' becomes '$ExePath'"
$script:fixed++
return
}
# Delete first. An advertised shortcut cannot be repointed in place: the
# Save() succeeds and the target stays exactly as it was.
try {
Remove-Item -LiteralPath $Path -Force -ErrorAction Stop
$sc = $wsh.CreateShortcut($Path)
$sc.TargetPath = $ExePath
$sc.WorkingDirectory = $workDir
$sc.IconLocation = "$ExePath,0"
$sc.Save()
} catch {
Log " WRITE FAIL $Path : $($_.Exception.Message)"
$script:failed++
return
}
# Read back. This is the whole point: a write that silently did nothing
# must be reported as a failure, not counted as a fix.
$after = Get-Target $Path
if ($after -eq $ExePath) {
Log " FIXED $Path : '$current' becomes '$ExePath'"
$script:fixed++
} else {
Log " VERIFY FAIL $Path : wrote '$ExePath', reads back '$after'"
$script:failed++
}
}
function Test-Claimed([System.IO.FileInfo]$File) {
if ($File.Name -ieq $leaf) { return $true }
if (-not $StalePathPattern) { return $false }
$t = Get-Target $File.FullName
return ($t -and $t -match [regex]::Escape($StalePathPattern))
}
foreach ($pair in @(@{ Dirs = $msiOwnedDirs; Msi = $true }, @{ Dirs = $ourDirs; Msi = $false })) {
foreach ($dir in $pair.Dirs) {
if (-not (Test-Path -LiteralPath $dir -PathType Container)) { continue }
foreach ($f in (Get-ChildItem -LiteralPath $dir -Filter '*.lnk' -File -EA SilentlyContinue)) {
if (Test-Claimed $f) { Repair-Lnk $f.FullName $pair.Msi }
}
}
}
try { [System.Runtime.InteropServices.Marshal]::ReleaseComObject($wsh) | Out-Null } catch {}
Log "=== done: $checked checked, $fixed fixed, $skipped skipped, $failed failed ==="
if ($failed -gt 0) { exit 1 }
exit 0

View File

@@ -0,0 +1,68 @@
# Select-KioskType.ps1
#
# Imaging-time picker that records which kind of kiosk this PC is, so GE-Enforce
# (share or Flask API) enforces the right scope. Writes the scope name to
# C:\Enrollment\pc-type.txt - the same file every other pc-type reads, and the
# value the Flask client passes as -Scope.
#
# Three kiosk subtypes (split out of the old generic gea-shopfloor-display):
# 1 lobbydisplay -> gea-shopfloor-lobbydisplay (lobby TV, dt\tv\slides)
# 2 dashboard -> gea-shopfloor-dashboard (shopfloor dashboard)
# 3 printerkiosk -> gea-shopfloor-printerkiosk (3D-printer kiosk)
#
# Usage:
# interactive (imaging operator picks): .\Select-KioskType.ps1
# unattended (automation / task seq): .\Select-KioskType.ps1 -Type dashboard
# already-deployed kiosk (one-time set): .\Select-KioskType.ps1 -Type lobbydisplay
#
# Idempotent: rewrites pc-type.txt to the chosen scope. Always exits 0.
param(
[ValidateSet('lobbydisplay', 'dashboard', 'printerkiosk')]
[string]$Type,
[string]$EnrollmentFile = 'C:\Enrollment\pc-type.txt'
)
$ErrorActionPreference = 'Continue'
$map = [ordered]@{
'1' = @{ Key = 'lobbydisplay'; Scope = 'gea-shopfloor-lobbydisplay'; Desc = 'Lobby display (lobby TV / slides)' }
'2' = @{ Key = 'dashboard'; Scope = 'gea-shopfloor-dashboard'; Desc = 'Shopfloor dashboard' }
'3' = @{ Key = 'printerkiosk'; Scope = 'gea-shopfloor-printerkiosk'; Desc = '3D-printer kiosk' }
}
function Resolve-ScopeFromType([string]$t) {
foreach ($k in $map.Keys) { if ($map[$k].Key -eq $t) { return $map[$k].Scope } }
return $null
}
$scope = $null
if ($Type) {
$scope = Resolve-ScopeFromType $Type
} else {
Write-Host ''
Write-Host 'Select this PC kiosk type:' -ForegroundColor Cyan
foreach ($k in $map.Keys) { Write-Host (" {0}) {1}" -f $k, $map[$k].Desc) }
Write-Host ''
do {
$choice = Read-Host 'Enter 1, 2, or 3'
} while (-not $map.Contains($choice))
$scope = $map[$choice].Scope
}
if (-not $scope) {
Write-Host "ERROR could not resolve a kiosk scope (Type='$Type')." -ForegroundColor Red
exit 0
}
try {
$dir = Split-Path -Parent $EnrollmentFile
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
$scope | Set-Content -NoNewline -LiteralPath $EnrollmentFile -ErrorAction Stop
Write-Host "wrote $EnrollmentFile = $scope" -ForegroundColor Green
} catch {
Write-Host "ERROR writing ${EnrollmentFile}: $($_.Exception.Message)" -ForegroundColor Red
}
exit 0

View File

@@ -0,0 +1,54 @@
# Set-EventSaverDisable.ps1
#
# Turn the EventSaver shopfloor screensaver OFF fleet-wide and undo its
# side effects. Runs under GE-Enforce (SYSTEM) every cycle. Idempotent +
# silent (SYSTEM context - no window).
#
# Self-excludes the canary test host: pass -ExceptHost <name> and this script
# does nothing on that PC, so the (canary-gated) enable entries keep it on
# there while every other shopfloor PC is cleaned.
#
# Undoes: screensaver registry (per-user), the EventSaver-Enable fallback
# task, the never-off power policy, and the staged .scr/.ini.
[CmdletBinding()]
param(
[string]$ExceptHost = '',
[string]$ScrPath = 'C:\Windows\System32\EventSaver.scr',
[string]$TaskName = 'EventSaver-Enable',
[int] $MonitorMinutes = 15, # restore a sane monitor-off (was Never)
[int] $StandbyMinutes = 20 # restore a sane sleep (was Never)
)
$ErrorActionPreference = 'Continue'
# leave the canary host alone - the enable entries own it there
if ($ExceptHost -and ($env:COMPUTERNAME -ieq $ExceptHost)) { exit 0 }
function Disable-Saver($deskKey) {
if (Test-Path $deskKey) {
Set-ItemProperty -Path $deskKey -Name 'ScreenSaveActive' -Value '0' -Type String -Force -ErrorAction SilentlyContinue
Remove-ItemProperty -Path $deskKey -Name 'SCRNSAVE.EXE' -ErrorAction SilentlyContinue
}
}
# 1. screensaver off in the default profile + every loaded user hive
Disable-Saver 'Registry::HKEY_USERS\.DEFAULT\Control Panel\Desktop'
Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue |
Where-Object { $_.PSChildName -match '^S-1-5-21' -and $_.PSChildName -notmatch '_Classes$' } |
ForEach-Object { Disable-Saver "Registry::HKEY_USERS\$($_.PSChildName)\Control Panel\Desktop" }
# 2. remove the self-clearing fallback task if present
if (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
}
# 3. restore power (undo the EventSaver never-off)
& powercfg /change monitor-timeout-ac $MonitorMinutes 2>&1 | Out-Null
& powercfg /change standby-timeout-ac $StandbyMinutes 2>&1 | Out-Null
# 4. remove staged binary + config (harmless if already gone)
Remove-Item -LiteralPath $ScrPath -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath ($ScrPath -replace '\.scr$', '.ini') -Force -ErrorAction SilentlyContinue
exit 0

View File

@@ -0,0 +1,29 @@
# Set-EventSaverPower.ps1
#
# Keep shopfloor monitors + PCs awake so the EventSaver ad screensaver is
# actually visible. Runs under GE-Enforce (SYSTEM) every cycle (Always).
# Sets monitor-off + sleep to Never on the ACTIVE power scheme, so a screen
# never blanks out from under the screensaver. Re-applies each cycle, so a
# power-plan change is corrected on the next enforce pass.
#
# Screensaver must trigger before any monitor-off would - with monitor-off
# set to Never here, the screensaver (2-10 min idle) always wins.
$ErrorActionPreference = 'Continue'
$logDir = 'C:\Logs\Shopfloor'
if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null }
$log = Join-Path $logDir 'eventsaver.log'
function Write-Log($m) {
Add-Content -LiteralPath $log -Value ("{0} {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $m)
}
# 0 = Never. Cover monitor + standby, AC + DC (shopfloor PCs are AC, DC is
# harmless belt-and-suspenders).
& powercfg /change monitor-timeout-ac 0 2>&1 | Out-Null
& powercfg /change monitor-timeout-dc 0 2>&1 | Out-Null
& powercfg /change standby-timeout-ac 0 2>&1 | Out-Null
& powercfg /change standby-timeout-dc 0 2>&1 | Out-Null
Write-Log "power: monitor-off + sleep set to Never (AC+DC) for EventSaver visibility"
exit 0

View File

@@ -0,0 +1,123 @@
# Set-EventSaverScreensaver.ps1
#
# Enable the EventSaver shopfloor screensaver for EVERY user on the box. Runs
# under GE-Enforce (SYSTEM) every cycle.
#
# WHY THIS WAS REWRITTEN (2026-08-06)
# The previous version wrote the timeout to exactly two places: HKU\.DEFAULT,
# which only seeds profiles created AFTERWARDS, and the hive of one profile
# whose folder had to be named literally 'Shopfloor'. On any PC where the
# operator signs in as anything else, no HKCU was ever touched - the screensaver
# then ran on whatever the profile already carried (a domain default, commonly
# 120 seconds), while GE-Enforce reported success every cycle because from its
# point of view it had done its job.
#
# That is what the "screensaver after 2 minutes instead of 9" reports were: the
# manifest said 480 and the machines had never been told.
#
# Now: seed .DEFAULT for future profiles, then apply to EVERY loaded user hive.
# Same approach Set-DisplayAlwaysOn.ps1 already uses for the kiosks. A user who
# signs in between cycles is picked up on the next one.
#
# IDEMPOTENT + SILENT: hives already correct are skipped, so the common path
# writes nothing.
[CmdletBinding()]
param(
[string]$ScrPath = 'C:\Windows\System32\EventSaver.scr',
[int] $TimeoutSeconds = 540,
# Retired. Kept so an older manifest passing -TargetUser does not fail to
# bind; it is deliberately ignored - targeting one named account is the bug
# this rewrite removes.
[string]$TargetUser = '',
[string]$TaskName = 'EventSaver-Enable'
)
$ErrorActionPreference = 'Continue'
$logDir = 'C:\Logs\Shopfloor'
if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null }
$log = Join-Path $logDir 'eventsaver.log'
function Write-Log($m) {
Add-Content -LiteralPath $log -Value ("{0} {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $m)
}
function Get-Val($deskKey, $name) {
return (Get-ItemProperty -Path $deskKey -Name $name -ErrorAction SilentlyContinue).$name
}
# Already what we want? Idempotency gate - keeps the common path silent.
function Saver-IsSet($deskKey) {
if (-not (Test-Path $deskKey)) { return $false }
if ((Get-Val $deskKey 'ScreenSaveActive') -ne '1') { return $false }
if ((Get-Val $deskKey 'SCRNSAVE.EXE') -ne $ScrPath) { return $false }
if ("$((Get-Val $deskKey 'ScreenSaveTimeOut'))" -ne "$TimeoutSeconds") { return $false }
return $true
}
function Set-SaverValues($deskKey) {
if (-not (Test-Path $deskKey)) { New-Item -Path $deskKey -Force | Out-Null }
Set-ItemProperty -Path $deskKey -Name 'ScreenSaveActive' -Value '1' -Type String -Force
Set-ItemProperty -Path $deskKey -Name 'SCRNSAVE.EXE' -Value $ScrPath -Type String -Force
Set-ItemProperty -Path $deskKey -Name 'ScreenSaveTimeOut' -Value "$TimeoutSeconds" -Type String -Force
Set-ItemProperty -Path $deskKey -Name 'ScreenSaverIsSecure' -Value '0' -Type String -Force
}
# --- 1. seed .DEFAULT so profiles created later start correct -----------------
$defKey = 'Registry::HKEY_USERS\.DEFAULT\Control Panel\Desktop'
if (-not (Saver-IsSet $defKey)) {
try { Set-SaverValues $defKey; Write-Log "seeded .DEFAULT ($TimeoutSeconds s)" }
catch { Write-Log "ERROR seeding .DEFAULT: $_" }
}
# --- 2. apply to every loaded human hive --------------------------------------
# Skipped: the three service accounts (SYSTEM, LOCAL SERVICE, NETWORK SERVICE)
# and the _Classes companions, which are not user desktops and would just add
# noise. Everything else that is loaded belongs to somebody signed in now.
$serviceSids = @('S-1-5-18', 'S-1-5-19', 'S-1-5-20')
$applied = 0
$already = 0
try {
$hives = Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction Stop | ForEach-Object { $_.PSChildName }
} catch {
Write-Log "ERROR enumerating HKEY_USERS: $_"
$hives = @()
}
foreach ($sid in $hives) {
if ($sid -eq '.DEFAULT') { continue } # handled above
if ($sid -like '*_Classes') { continue }
if ($serviceSids -contains $sid) { continue }
if ($sid -notlike 'S-1-5-21-*') { continue } # real domain/local users only
$hiveKey = "Registry::HKEY_USERS\$sid\Control Panel\Desktop"
if (Saver-IsSet $hiveKey) { $already++; continue }
try {
Set-SaverValues $hiveKey
$applied++
Write-Log "applied to hive $sid ($TimeoutSeconds s)"
} catch {
Write-Log "ERROR writing hive ${sid}: $_"
}
}
if ($applied -eq 0 -and $already -eq 0) {
# Nobody signed in - normal during imaging or on an idle bay. .DEFAULT above
# covers the next profile, and the next cycle after a logon covers the rest.
Write-Log 'no user hives loaded; .DEFAULT seeded, will apply on a later cycle'
}
# --- 3. remove the old per-user fallback task ---------------------------------
# The previous version registered an AtLogon task for the hardcoded 'Shopfloor'
# account. On machines where nobody signs in as that, it sat queued forever and
# never fired. Applying to loaded hives every cycle replaces it, so clear any
# that are still registered.
try {
if (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
Write-Log "removed stale fallback task '$TaskName'"
}
} catch { }
exit 0

View File

@@ -0,0 +1,46 @@
# Set-FmsHostsEntry.ps1 - idempotently pin FMS host in C:\Windows\System32\drivers\etc\hosts.
#
# Why pinned:
# eDNC's FMS prescan (CPreScan::Initialise_Sockets in DncMain.exe and
# CDoPersonnel::InitializeSocket in DNCdll.dll) resolves the FMS host
# via MFC CSocket, which calls inet_addr first then gethostbyname. The
# legacy WinSock1 resolver path fails on the GE corporate network for
# wjfms3.ae.ge.com (modern getaddrinfo path used by PowerShell works
# fine, but eDNC does not use it). Hosts file entry is consulted by
# gethostbyname before any DNS query, so the pin short-circuits the
# broken legacy path.
#
# Idempotent: adds line if missing, leaves it alone if already present.
# Safe to run every cycle (DetectionMethod=Always in manifest).
$ErrorActionPreference = 'Stop'
$hostsPath = Join-Path $env:windir 'System32\drivers\etc\hosts'
$ip = '10.233.112.158'
$fqdn = 'WJFMS3.AE.GE.COM'
$line = "$ip`t$fqdn"
if (-not (Test-Path $hostsPath)) {
Write-Host "hosts file not found at $hostsPath - aborting"
exit 1
}
$content = Get-Content -LiteralPath $hostsPath -ErrorAction Stop
# Match any non-comment line that maps either the IP or the FQDN.
# Drops stale or wrong mappings of the same FQDN/IP, then appends the canonical pin.
$pattern = '(?i)^\s*[^#\s]+\s+\S*' + [regex]::Escape($fqdn) + '\b|^\s*' + [regex]::Escape($ip) + '\s'
$existing = $content | Where-Object { $_ -match $pattern }
$canonical = ($existing | Where-Object { $_ -match "^\s*$([regex]::Escape($ip))\s+$([regex]::Escape($fqdn))\s*$" })
if ($canonical -and $existing.Count -eq @($canonical).Count) {
# Already pinned correctly. No change.
exit 0
}
# Either no entry, or an entry exists with wrong IP/FQDN/casing/whitespace. Rewrite.
$kept = $content | Where-Object { $_ -notmatch $pattern }
$new = @($kept) + $line
Set-Content -LiteralPath $hostsPath -Value $new -Encoding ascii -ErrorAction Stop
Write-Host "Wrote FMS hosts pin: $line"
exit 0

View File

@@ -0,0 +1,58 @@
# Set-OpenTextToolbar.ps1 - replace the HostExplorer default VT toolbar.
#
# Copies the toolbar shipped in this share over the local one at:
# C:\ProgramData\Hummingbird\Connectivity\15.00\Shared\HostExplorer\Toolbar\
#
# Runs from the common manifest with DetectionMethod Always, so it also
# self-heals if the file is changed locally.
#
# Deliberately does NOT bump the OpenText version. Setup-OpenText.ps1 skips
# unless HKLM:\SOFTWARE\GE\OpenText\Installed differs from version.txt, so a
# bump re-runs the whole thing - MSI plus the SP1 patch - on every OpenText
# machine over SMB, to ship a 565-byte file. This entry copies just the file.
$ErrorActionPreference = 'Continue'
$fileName = 'Default VT Toolbar.tbv'
$src = Join-Path $PSScriptRoot "..\apps\opentext\HostExplorer\Toolbar\$fileName"
$dstDir = 'C:\ProgramData\Hummingbird\Connectivity\15.00\Shared\HostExplorer\Toolbar'
$dst = Join-Path $dstDir $fileName
Write-Host '=== OpenText VT toolbar ==='
if (-not (Test-Path -LiteralPath $src)) {
Write-Host " source missing on share: $src - nothing to do."
return
}
# Gate on OpenText actually being installed. The Shared root only exists once
# HostExplorer content has been deployed, so its absence means this machine has
# no OpenText and we should not create a stray Hummingbird tree.
$sharedRoot = 'C:\ProgramData\Hummingbird\Connectivity\15.00\Shared'
if (-not (Test-Path -LiteralPath $sharedRoot)) {
Write-Host ' OpenText not installed on this PC - skipping.'
return
}
# Skip when identical, so an Always entry is not rewriting the file every cycle.
if (Test-Path -LiteralPath $dst) {
$sh = (Get-FileHash -LiteralPath $src -Algorithm SHA256).Hash
$dh = (Get-FileHash -LiteralPath $dst -Algorithm SHA256).Hash
if ($sh -eq $dh) {
Write-Host ' already current - no change.'
return
}
Write-Host ' local copy differs - replacing.'
} else {
Write-Host ' not present locally - installing.'
}
try {
if (-not (Test-Path -LiteralPath $dstDir)) {
New-Item -ItemType Directory -Path $dstDir -Force -ErrorAction Stop | Out-Null
}
Copy-Item -LiteralPath $src -Destination $dst -Force -ErrorAction Stop
Write-Host " replaced $dst"
} catch {
Write-Warning " failed to replace ${dst}: $_"
}

View File

@@ -0,0 +1,137 @@
# Set-ShopdbCollectorKey.ps1
#
# Delivers the ShopDB collector credential to every shopfloor PC by writing
# HKLM:\SOFTWARE\GE\ShopDB (BaseUrl + CollectorKey), the same contract the
# display kiosks already use. Anything that posts to /api/collector/* reads it
# from there - today Backup-NtlarsSettings.ps1, tomorrow whatever else reports.
#
# WHY A KEY IS NEEDED AT ALL:
# The GE-Enforce manifest/payload FETCH honours an IP allowlist, so a bay on a
# trusted subnet pulls config with no token. Collector INGEST does not: it
# accepts only a collector-scoped token. That asymmetry is deliberate - fetch
# reads config we already publish, ingest WRITES asset data, and an allowlist
# alone would let anything on the subnet post revisions.
#
# WHERE THE SECRET LIVES:
# NOT in manifest.json, and not in this script. It is read from a sibling file
# on the share (see $KEYCONFIG below), so:
# - the manifest stays free of secrets and safe to read/diff
# - rotating the token is replacing ONE file, not editing a manifest
# The file inherits the share's ACL, which grants file-level reads only to the
# SFLD user. Treat it as a secret: scope the token to collector.ingest ONLY,
# so a leak cannot read or mutate anything else.
#
# IDEMPOTENT: compares current registry values against desired and writes only
# on a difference, so DetectionMethod=Always costs one registry read per cycle
# after the first run. Rotating the key on the share re-converges the fleet on
# the next cycle with no other action.
#
# Always exits 0 so the GE-Enforce "last run result" stays clean.
param(
# Override for a one-off run; normally read from the sibling config file.
[string]$CollectorKey,
[string]$BaseUrl
)
$ErrorActionPreference = 'Continue'
$SHOPDBREG = 'HKLM:\SOFTWARE\GE\ShopDB'
$KEYCONFIG = Join-Path $PSScriptRoot '..\configs\shopdb-collector.txt'
$LOGDIR = 'C:\Logs\Shopfloor'
$LOGFILE = Join-Path $LOGDIR 'shopdb-collector-key.log'
if (-not (Test-Path $LOGDIR)) {
New-Item -ItemType Directory -Path $LOGDIR -Force -EA SilentlyContinue | Out-Null
}
function Log {
param([string]$Message)
$line = '[{0}] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
Add-Content -Path $LOGFILE -Value $line -EA SilentlyContinue
Write-Host $line
}
function Read-KeyConfig {
<#
LABELLED lines only:
baseurl=https://host/shopdb
collector=<collector.ingest token>
A bare line is ignored rather than guessed at - a fetch token landing in
the collector slot would leave reporting broken while looking configured.
#>
$result = @{ BaseUrl = ''; CollectorKey = '' }
if (-not (Test-Path $KEYCONFIG)) { return $result }
foreach ($line in (Get-Content $KEYCONFIG -EA SilentlyContinue)) {
$t = $line.Trim()
if (-not $t -or $t.StartsWith('#')) { continue }
if ($t -match '^(?i)collector\s*=\s*(.+)$') { $result.CollectorKey = $Matches[1].Trim() }
elseif ($t -match '^(?i)baseurl\s*=\s*(.+)$') { $result.BaseUrl = $Matches[1].Trim().TrimEnd('/') }
else { Log " ignoring unlabelled line in $(Split-Path $KEYCONFIG -Leaf)" }
}
return $result
}
Log '=== Set-ShopdbCollectorKey start ==='
$cfg = Read-KeyConfig
if (-not $CollectorKey) { $CollectorKey = $cfg.CollectorKey }
if (-not $BaseUrl) { $BaseUrl = $cfg.BaseUrl }
if (-not $CollectorKey) {
# Not an error: an unconfigured share is the normal state before a site
# issues its token. Say exactly what to do rather than failing silently.
Log "No collector key configured. Put a 'collector=<token>' line in:"
Log " $KEYCONFIG"
Log "Nothing written."
exit 0
}
# --- compare before writing ------------------------------------------------
$current = $null
try { $current = Get-ItemProperty -Path $SHOPDBREG -EA Stop } catch { }
$needKey = (-not $current) -or ($current.CollectorKey -ne $CollectorKey)
$needUrl = $BaseUrl -and ((-not $current) -or ($current.BaseUrl -ne $BaseUrl))
if (-not $needKey -and -not $needUrl) {
Log 'Registry already matches - nothing to do.'
Log '=== Set-ShopdbCollectorKey end ==='
exit 0
}
try {
if (-not (Test-Path $SHOPDBREG)) { New-Item -Path $SHOPDBREG -Force | Out-Null }
if ($needUrl) {
New-ItemProperty -Path $SHOPDBREG -Name BaseUrl -Value $BaseUrl `
-PropertyType String -Force -EA Stop | Out-Null
Log "Set BaseUrl = $BaseUrl"
}
if ($needKey) {
New-ItemProperty -Path $SHOPDBREG -Name CollectorKey -Value $CollectorKey `
-PropertyType String -Force -EA Stop | Out-Null
Log "Set CollectorKey (length $($CollectorKey.Length)) - value not logged"
}
# Lock the key down to SYSTEM + Administrators, matching what
# Install-ShopdbKiosk does on displays. Without this the value is readable
# by any interactive user, and a shopfloor PC is a shared login.
try {
$acl = Get-Acl $SHOPDBREG
$acl.SetAccessRuleProtection($true, $false)
foreach ($who in 'SYSTEM', 'Administrators') {
$acl.AddAccessRule((New-Object Security.AccessControl.RegistryAccessRule(
$who, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')))
}
Set-Acl -Path $SHOPDBREG -AclObject $acl -EA Stop
Log 'ACL set: SYSTEM + Administrators only.'
} catch {
Log "WARNING - could not tighten the ACL: $_"
}
} catch {
Log "FAILED to write ${SHOPDBREG}: $_"
}
Log '=== Set-ShopdbCollectorKey end ==='
exit 0

View File

@@ -0,0 +1,15 @@
@echo off
REM Setup-OpenText.cmd - launcher for Setup-OpenText.ps1.
REM
REM Lives in <type>/scripts/. Setup-OpenText.ps1 expects to find the four
REM OpenText binaries (OpenTextHostExplorer15x64.msi/.cab/.msp + ShopFloorx64.mst)
REM plus version.txt in $SourceDir. We pass ..\apps\opentext\ explicitly so the
REM script doesn't fall back to its $PSScriptRoot default (which would be the
REM scripts/ dir, where the binaries don't live).
REM
REM Mirrors the Install-AcroReader.cmd / Install-Oracle11r2.cmd pattern of
REM script-in-scripts, payload-in-apps. Called by Install-FromManifest.ps1
REM (Type=CMD); exit code surfaces back to the enforcer.
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Setup-OpenText.ps1" -SourceDir "%~dp0..\apps\opentext"
exit /b %errorlevel%

View File

@@ -0,0 +1,361 @@
#!/usr/bin/env pwsh
# Setup-OpenText.ps1 - OpenText HostExplorer 15 SP1 ShopFloor installer + profile
# deployment, callable from both PXE PreInstall and Intune DSC paths.
#
# WHY THIS EXISTS:
# The vendor-supplied OpenText.exe (Inno Setup wrapper built by WJDT) bundles
# the install steps but its [Files] section deploys per-user content to
# {userappdata} - which resolves to SYSTEM's profile under DSC and to a single
# user under PreInstall. As a result the operator (logging in via Azure AD)
# never sees the profiles, keymaps, menus, or macros - only the installed
# binaries. This script replaces OpenText.exe entirely, doing the same install
# steps via direct msiexec calls AND fanning the per-user content out to:
# - %ProgramData%\Hummingbird\Connectivity\15.00\Shared\
# - C:\Users\Default\AppData\Roaming\Hummingbird\Connectivity\15.00\
# - Each existing user profile under C:\Users\
#
# INVOKED BY:
# - PreInstall: Setup-OpenText.cmd wrapper (because the runner only knows MSI/EXE)
# - DSC: Install-OpenText.ps1 downloads the bundled tree from blob, then
# invokes this script with -SourceDir <temp dir>
#
# DETECTION:
# Skips if HKLM:\SOFTWARE\GE\OpenText\Installed = $expectedVersion. Marker is
# written at the end of a successful run, so the runner / DSC wrapper can
# no-op on subsequent invocations.
[CmdletBinding()]
param(
# Override when invoked from a temp dir (DSC path) where the bundled files were
# just downloaded. Defaults to $PSScriptRoot, but resolved INSIDE the script body
# below - PowerShell evaluates `param([string]$X = $PSScriptRoot)` at parameter-
# binding time, when $PSScriptRoot may not yet be populated, so the default winds
# up as an empty string. Setting it in the body works because $PSScriptRoot is
# reliably populated by then.
[string]$SourceDir
)
$ErrorActionPreference = 'Stop'
if (-not $SourceDir) {
$SourceDir = $PSScriptRoot
}
# Normalize $SourceDir to a canonical absolute path. The CMD shim passes
# "%~dp0..\apps\opentext" which embeds a literal "..". msiexec / the Windows
# Installer service fail to open the package with that unresolved segment
# (exit 1619, ERROR_INSTALL_PACKAGE_OPEN_FAILED), even though every other
# .NET / PowerShell API resolves it fine. Resolve-Path collapses ".." into
# a clean drive-rooted path before any msiexec invocation.
if (Test-Path -LiteralPath $SourceDir) {
try { $SourceDir = (Resolve-Path -LiteralPath $SourceDir).ProviderPath } catch {}
}
# --- Inline site-config reader (this script runs from C:\PreInstall\installers\opentext\,
# NOT from C:\Enrollment\shopfloor-setup\, so it can't dot-source Get-PCProfile.ps1) ---
function Get-SiteConfig {
$configPath = 'C:\Enrollment\site-config.json'
if (-not (Test-Path $configPath)) { return $null }
try {
return Get-Content $configPath -Raw | ConvertFrom-Json
} catch {
return $null
}
}
# --- Logging (set up FIRST so any startup error - missing version.txt, broken
# bundled file, etc. - lands in the log file instead of disappearing into the
# runner's stdout void) ---
$logDir = 'C:\Logs\PreInstall'
$logFile = Join-Path $logDir 'Setup-OpenText.log'
$msiLog = Join-Path $logDir 'Setup-OpenText-msi.log'
$mspLog = Join-Path $logDir 'Setup-OpenText-msp.log'
if (-not (Test-Path $logDir)) {
New-Item -Path $logDir -ItemType Directory -Force | Out-Null
}
function Write-SetupLog {
param([string]$Message)
$line = "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] $Message"
Add-Content -Path $logFile -Value $line -ErrorAction SilentlyContinue
Write-Host $line
}
Write-SetupLog "================================================================"
Write-SetupLog "=== Setup-OpenText.ps1 starting ==="
Write-SetupLog "================================================================"
Write-SetupLog "SourceDir: $SourceDir"
Write-SetupLog "PSScriptRoot: $PSScriptRoot"
Write-SetupLog "Running as: $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)"
# --- Config ---
# Version is read from version.txt next to this script. ONE source of truth: bumping
# version.txt is the only edit needed when shipping a new OpenText build. Setup-
# OpenText.ps1 itself, Install-OpenText.ps1 (DSC wrapper), and the registry marker
# all derive their notion of "expected version" from this file.
$versionFile = Join-Path $SourceDir 'version.txt'
Write-SetupLog "Looking for version.txt at: $versionFile"
if (-not (Test-Path $versionFile)) {
Write-SetupLog "ERROR: version.txt not found in $SourceDir - cannot determine expected version."
Write-SetupLog "Directory listing of $SourceDir :"
if (Test-Path $SourceDir) {
Get-ChildItem $SourceDir -ErrorAction SilentlyContinue | ForEach-Object {
Write-SetupLog " $($_.Name) $($_.Length) bytes"
}
} else {
Write-SetupLog " (SourceDir does not exist)"
}
exit 1
}
$expectedVersion = (Get-Content -Path $versionFile -Raw -ErrorAction Stop).Trim()
if (-not $expectedVersion) {
Write-SetupLog "ERROR: version.txt at $versionFile is empty."
exit 1
}
Write-SetupLog "Expected version (from version.txt): $expectedVersion"
# --- Detection: skip if already deployed at expected version ---
$markerKey = 'HKLM:\SOFTWARE\GE\OpenText'
$markerVal = 'Installed'
if (Test-Path $markerKey) {
$installed = (Get-ItemProperty -Path $markerKey -Name $markerVal -ErrorAction SilentlyContinue).$markerVal
if ($installed -eq $expectedVersion) {
Write-SetupLog "OpenText $expectedVersion already deployed (marker present) - skipping."
exit 0
}
Write-SetupLog "OpenText marker mismatch (found '$installed', expected '$expectedVersion') - re-deploying."
}
else {
Write-SetupLog "OpenText marker not present - first install."
}
# --- Verify bundled files are where we expect ---
$msiPath = Join-Path $SourceDir 'OpenTextHostExplorer15x64.msi'
$cabPath = Join-Path $SourceDir 'OpenTextHostExplorer15x64.cab'
$mspPath = Join-Path $SourceDir 'OpenTextHostExplorer15x64_ServicePack1.msp'
$mstPath = Join-Path $SourceDir 'ShopFloorx64.mst'
foreach ($f in @($msiPath, $cabPath, $mspPath, $mstPath)) {
if (-not (Test-Path $f)) {
Write-SetupLog "ERROR: required file not found: $f"
exit 1
}
}
# --- Step 1: Install the base MSI with the ShopFloor transform ---
# NOTE: We deliberately do NOT pass REBOOT=ReallySuppress here even though we do
# for the VC++ MSIs. OpenText HostExplorer installs shell extensions that hook
# explorer.exe, and the MSI uses Restart Manager to ask explorer to close so the
# in-use shell DLLs can be replaced. With REBOOT=ReallySuppress, RM closes
# explorer.exe but interprets "restart explorer" as a reboot action and refuses
# to relaunch it - leaving the user without a desktop. /norestart on its own
# prevents the actual Windows reboot but lets RM cleanly close-and-relaunch
# explorer mid-install. msiexec still returns 3010 ("reboot would be needed"),
# which we treat as success below.
Write-SetupLog ""
Write-SetupLog "Step 1: Installing OpenTextHostExplorer15x64.msi with ShopFloorx64.mst..."
if (Test-Path $msiLog) { Remove-Item $msiLog -Force -ErrorAction SilentlyContinue }
$msiArgs = "/i `"$msiPath`" TRANSFORMS=`"$mstPath`" /qn /norestart /L*v `"$msiLog`""
Write-SetupLog " msiexec.exe $msiArgs"
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = 'msiexec.exe'
$psi.Arguments = $msiArgs
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$proc = [System.Diagnostics.Process]::Start($psi)
$proc.WaitForExit()
$msiExit = $proc.ExitCode
Write-SetupLog " msiexec exit code: $msiExit"
if ($msiExit -ne 0 -and $msiExit -ne 3010) {
Write-SetupLog "ERROR: base MSI install failed (exit $msiExit). See $msiLog"
exit 1
}
if ($msiExit -eq 3010) {
Write-SetupLog " (3010 = reboot needed but suppressed)"
}
# --- Step 2: Apply Service Pack 1 patch ---
# Same Restart Manager rationale as Step 1 - skip REBOOT=ReallySuppress so RM
# can relaunch explorer.exe after replacing the patched shell extension DLLs.
Write-SetupLog ""
Write-SetupLog "Step 2: Applying SP1 patch..."
if (Test-Path $mspLog) { Remove-Item $mspLog -Force -ErrorAction SilentlyContinue }
$mspArgs = "/p `"$mspPath`" /qn /norestart /L*v `"$mspLog`""
Write-SetupLog " msiexec.exe $mspArgs"
$psi.Arguments = $mspArgs
$proc = [System.Diagnostics.Process]::Start($psi)
$proc.WaitForExit()
$mspExit = $proc.ExitCode
Write-SetupLog " msiexec exit code: $mspExit"
if ($mspExit -ne 0 -and $mspExit -ne 3010) {
Write-SetupLog "ERROR: SP1 patch failed (exit $mspExit). See $mspLog"
exit 1
}
# --- Step 3: Deploy profiles, keymaps, menus, accessories ---
# Source layout (bundled with this script):
# $SourceDir\Profile\*.hep
# $SourceDir\Accessories\EB\*.eb*
# $SourceDir\HostExplorer\Keymap\*.kmv
# $SourceDir\HostExplorer\Menu\*.hmv
#
# Target layouts (Hummingbird-canonical):
# %ProgramData%\Hummingbird\Connectivity\15.00\Shared\Profile\
# %ProgramData%\Hummingbird\Connectivity\15.00\Shared\Accessories\EB\
# %ProgramData%\Hummingbird\Connectivity\15.00\Shared\HostExplorer\Keymap\
# %ProgramData%\Hummingbird\Connectivity\15.00\Shared\HostExplorer\Menu\
# <user>\AppData\Roaming\Hummingbird\Connectivity\15.00\Profile\
# <user>\AppData\Roaming\Hummingbird\Connectivity\15.00\Accessories\EB\
# <user>\AppData\Roaming\Hummingbird\Connectivity\15.00\HostExplorer\Keymap\
# <user>\AppData\Roaming\Hummingbird\Connectivity\15.00\HostExplorer\Menu\
#
# We deploy to ProgramData\Shared (system-wide fallback), Default User (template
# inherited by every NEW user profile), and every existing user profile (so
# already-created accounts like SupportUser get them immediately).
Write-SetupLog ""
Write-SetupLog "Step 3: Deploying profiles/keymaps/menus/macros..."
# --- Resolve exclude lists from site-config.json (falls back to West Jefferson defaults) ---
$siteConfig = Get-SiteConfig
$profileExcludes = if ($siteConfig -and $siteConfig.opentext -and $siteConfig.opentext.excludeProfiles) {
@($siteConfig.opentext.excludeProfiles)
} else {
@('WJ_Office.hep', 'IBM_qks.hep', 'mmcs.hep') # West Jefferson defaults
}
$shortcutExcludes = if ($siteConfig -and $siteConfig.opentext -and $siteConfig.opentext.excludeShortcuts) {
@($siteConfig.opentext.excludeShortcuts)
} else {
@('WJ_Office.lnk', 'IBM_qks.lnk', 'mmcs.lnk')
}
if ($siteConfig) {
Write-SetupLog "Site config loaded - profile excludes: $($profileExcludes -join ', ')"
Write-SetupLog "Site config loaded - shortcut excludes: $($shortcutExcludes -join ', ')"
} else {
Write-SetupLog "No site-config.json found - using West Jefferson defaults for excludes"
}
# Map of source subdir -> destination subdir relative to the Hummingbird root.
# Optional Exclude list drops specific filenames from both the source-to-dest
# copy AND from the destination if they were left over from a prior install.
$contentMap = @(
@{
Src = 'Profile'
Dst = 'Profile'
Exclude = $profileExcludes
}
@{ Src = 'Accessories\EB'; Dst = 'Accessories\EB' }
@{ Src = 'HostExplorer\Keymap'; Dst = 'HostExplorer\Keymap' }
@{ Src = 'HostExplorer\Menu'; Dst = 'HostExplorer\Menu' }
)
function Copy-HummingbirdContent {
param(
[string]$RootDst, # Hummingbird root, e.g. C:\ProgramData\Hummingbird\Connectivity\15.00\Shared
[string]$Label
)
foreach ($entry in $contentMap) {
$srcPath = Join-Path $SourceDir $entry.Src
if (-not (Test-Path $srcPath)) { continue }
$dstPath = Join-Path $RootDst $entry.Dst
New-Item -Path $dstPath -ItemType Directory -Force | Out-Null
# Remove any previously-deployed excluded files from the destination
# - handles the case where a PC got them from an older install.
if ($entry.Exclude) {
foreach ($name in $entry.Exclude) {
$stale = Join-Path $dstPath $name
if (Test-Path -LiteralPath $stale) {
try {
Remove-Item -LiteralPath $stale -Force -ErrorAction Stop
Write-SetupLog " $Label : removed stale $name"
} catch {
Write-SetupLog " $Label : failed to remove $stale : $_"
}
}
}
}
$files = Get-ChildItem -Path $srcPath -File -ErrorAction SilentlyContinue
if ($entry.Exclude) {
$files = @($files | Where-Object { $entry.Exclude -notcontains $_.Name })
}
foreach ($f in $files) {
Copy-Item -Path $f.FullName -Destination $dstPath -Force
}
Write-SetupLog " $Label : $($entry.Src) -> $dstPath ($($files.Count) files)"
}
}
# 3a. ProgramData Shared
$sharedRoot = Join-Path $env:ProgramData 'Hummingbird\Connectivity\15.00\Shared'
Copy-HummingbirdContent -RootDst $sharedRoot -Label 'Shared'
# 3b. Default User (template for new user profiles)
$defaultUserRoot = 'C:\Users\Default\AppData\Roaming\Hummingbird\Connectivity\15.00'
Copy-HummingbirdContent -RootDst $defaultUserRoot -Label 'Default User'
# 3c. Every existing user profile under C:\Users\
$skipNames = @('Default', 'Default User', 'Public', 'defaultuser0', 'All Users', 'WDAGUtilityAccount')
$userDirs = Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue |
Where-Object { $skipNames -notcontains $_.Name -and (Test-Path "$($_.FullName)\AppData\Roaming") }
foreach ($u in $userDirs) {
$userRoot = Join-Path $u.FullName 'AppData\Roaming\Hummingbird\Connectivity\15.00'
Copy-HummingbirdContent -RootDst $userRoot -Label $u.Name
}
# --- Step 4: Public Desktop shortcuts ---
# Uses $shortcutExcludes (resolved from site-config.json above) to skip
# deploying unwanted .lnk files AND remove any a prior install left behind.
Write-SetupLog ""
Write-SetupLog "Step 4: Deploying public desktop shortcuts..."
$shortcutSrc = Join-Path $SourceDir 'W10shortcuts'
$publicDesktop = 'C:\Users\Public\Desktop'
# Clean up stale copies from prior installs first
foreach ($name in $shortcutExcludes) {
$stale = Join-Path $publicDesktop $name
if (Test-Path -LiteralPath $stale) {
try {
Remove-Item -LiteralPath $stale -Force -ErrorAction Stop
Write-SetupLog " removed stale desktop shortcut: $name"
} catch {
Write-SetupLog " failed to remove stale $stale : $_"
}
}
}
if (Test-Path $shortcutSrc) {
$lnkFiles = Get-ChildItem -Path $shortcutSrc -Filter '*.lnk' -File -ErrorAction SilentlyContinue
foreach ($l in $lnkFiles) {
if ($shortcutExcludes -contains $l.Name) {
Write-SetupLog " skip (excluded): $($l.Name)"
continue
}
Copy-Item -Path $l.FullName -Destination $publicDesktop -Force
Write-SetupLog " $($l.Name) -> $publicDesktop"
}
}
# --- Step 5: Write registry marker ---
Write-SetupLog ""
Write-SetupLog "Step 5: Writing registry marker..."
if (-not (Test-Path $markerKey)) {
New-Item -Path $markerKey -Force | Out-Null
}
Set-ItemProperty -Path $markerKey -Name $markerVal -Value $expectedVersion -Force
Set-ItemProperty -Path $markerKey -Name 'InstalledAt' -Value (Get-Date -Format 'o') -Force
Write-SetupLog ""
Write-SetupLog "================================================================"
Write-SetupLog "=== OpenText HostExplorer ShopFloor $expectedVersion deployed ==="
Write-SetupLog "================================================================"
exit 0

View File

@@ -0,0 +1,82 @@
# Test-RegExport.ps1
#
# Exercises Backup-NtlarsSettings.ps1's Export-DncToReg against MOCK registry
# keys, so the formatting can be verified on a machine with no registry (a
# Linux dev box running PowerShell Core, for instance).
#
# Why this exists: bad escaping or a wrong dword format produces a .reg that
# looks fine, imports without complaint, and only reveals itself when a tech
# restores a machine and the settings are subtly wrong. That failure is far too
# late and far too expensive, so the formatting gets tested away from the bay.
#
# Writes the generated .reg to -OutFile so the ShopDB-side Python codec can
# parse it in the same run and confirm both ends agree.
#
# pwsh -NoProfile -File Test-RegExport.ps1 -OutFile /tmp/mock.reg
param(
[string]$OutFile = './mock-export.reg'
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'Backup-NtlarsSettings.ps1')
function New-MockKey {
<#
A stand-in for a Microsoft.Win32.RegistryKey. PowerShell is duck-typed,
so Export-DncToReg only needs .Name, .GetValueNames(), .GetValueKind()
and .GetValue().
#>
param(
[Parameter(Mandatory)][string]$Name,
[Parameter(Mandatory)][hashtable]$Values # name -> @{ Kind; Data }
)
$key = [pscustomobject]@{ Name = $Name; _values = $Values }
$key | Add-Member ScriptMethod GetValueNames { $this._values.Keys } -Force
$key | Add-Member ScriptMethod GetValueKind {
param($n) $this._values[$n].Kind } -Force
$key | Add-Member ScriptMethod GetValue {
param($n) $this._values[$n].Data } -Force
return $key
}
$root = 'HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC'
$keys = @(
New-MockKey -Name $root -Values @{
'COMPUTERNAME' = @{ Kind = 'String'; Data = 'GGBX0NH3ESF' }
}
New-MockKey -Name "$root\General" -Values @{
'MachineNo' = @{ Kind = 'String'; Data = '3204' }
'Cnc' = @{ Kind = 'String'; Data = 'OKUMA' }
'HostType' = @{ Kind = 'String'; Data = 'WILM' }
}
New-MockKey -Name "$root\Btr" -Values @{
'BTR Rate' = @{ Kind = 'String'; Data = '300' }
'Auto Rewind' = @{ Kind = 'String'; Data = 'YES' }
'CmntLag' = @{ Kind = 'DWord'; Data = 0 }
'BigCount' = @{ Kind = 'DWord'; Data = 4294967295 }
}
# The nasty cases: characters that must be escaped, and the wide/binary
# value kinds that do not occur in the current corpus but are legal.
New-MockKey -Name "$root\EdgeCases" -Values @{
'Path With Backslash' = @{ Kind = 'String'; Data = 'C:\Program Files\GE' }
'Has "Quotes"' = @{ Kind = 'String'; Data = 'say "hi"' }
'Empty' = @{ Kind = 'String'; Data = '' }
'Expanded' = @{ Kind = 'ExpandString'; Data = '%SystemRoot%\dnc' }
'Multi' = @{ Kind = 'MultiString'; Data = @('one','two') }
'Blob' = @{ Kind = 'Binary'; Data = [byte[]](1,2,255) }
'Big' = @{ Kind = 'QWord'; Data = [uint64]1234567890123 }
}
)
$text = Export-DncToReg -Keys $keys
# UTF-16LE + BOM, matching what the real script posts.
$bytes = [byte[]](0xFF, 0xFE) + [Text.Encoding]::Unicode.GetBytes($text)
[IO.File]::WriteAllBytes($OutFile, $bytes)
Write-Host "Wrote $OutFile ($($bytes.Length) bytes)"
Write-Host '--- first lines ---'
($text -split "`r`n" | Select-Object -First 12) | ForEach-Object { Write-Host " $_" }

View File

@@ -0,0 +1,249 @@
# Test-ShareAccess.ps1 - why can this PC not reach the share?
#
# READ-ONLY. It maps nothing, deletes nothing, stores no credential and prints
# no password. Safe to run on a production bay while somebody is working.
#
# THE PROBLEM IT EXISTS FOR
#
# Shopfloor PCs intermittently lose S: - it prompts for credentials - while
# navigating the identical UNC path in Explorer works fine. That combination has
# several possible causes which look identical from the desk, and the fix
# differs for each:
#
# 1. Persistent reconnect raced the network. The mapping is restored early in
# logon, before wifi/802.1X is up, so it comes back dead. A manual UNC
# visit minutes later works because by then the server is reachable.
# 2. The stored credential does not cover the name form in use. Credential
# Manager matches by STRING: an entry for "server" does not serve
# "server.domain.com". One path finds the account, the other does not.
# 3. Two identities for one server. Windows allows ONE SMB session per server
# per logon session, so if the drive wants a shared account and something
# already opened that server as the logged-on user (or by a second name
# form), the loser gets a credential prompt rather than a clear error.
# 4. Genuinely stale password, after a rotation.
#
# This collects the evidence that separates them, and says which it looks like.
#
# CONTEXT MATTERS: drive mappings and SMB sessions are PER LOGON SESSION. Run it
# as the logged-on user to diagnose S:. Run it as SYSTEM (psexec -s, or a
# GE-Enforce manifest entry) to diagnose W: and the backup scripts. The two see
# different worlds, and a mapping visible in one is genuinely absent in the
# other - that is not a bug.
#
# Log: C:\Logs\Shopfloor\share-access-<yyyyMMdd-HHmmss>.log
param(
# Server to test. Both the short and fully-qualified forms are exercised,
# because a mismatch between them is one of the causes.
[string]$Server = 'tsgwp00525.wjs.geaerospace.net',
# Share to probe on that server.
[string]$Share = 'shared',
# Drive letters to report on. S: is the user's shopfloor mapping, W: is the
# one GE-Enforce makes as SYSTEM for the duration of its cycle.
[string[]]$Drives = @('S:', 'W:')
)
$ErrorActionPreference = 'Continue'
$logDir = 'C:\Logs\Shopfloor'
if (-not (Test-Path $logDir)) {
try { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } catch { $logDir = $env:TEMP }
}
$logFile = Join-Path $logDir ('share-access-{0}.log' -f (Get-Date -Format 'yyyyMMdd-HHmmss'))
function Say {
param([string]$Text = '')
Write-Host $Text
Add-Content -Path $logFile -Value $Text -ErrorAction SilentlyContinue
}
function Section {
param([string]$Title)
Say ''
Say ('=== {0} ' -f $Title).PadRight(70, '=')
}
$short = ($Server -split '\.')[0]
$forms = @($Server, $short) | Select-Object -Unique
$findings = New-Object System.Collections.Generic.List[string]
Say ('Test-ShareAccess {0}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'))
Say ('Log: {0}' -f $logFile)
# ---------------------------------------------------------------------------
Section 'Who is running this'
# Everything below is scoped to this identity and this logon session. A drive
# absent here may be perfectly healthy in another session.
$me = [Security.Principal.WindowsIdentity]::GetCurrent()
$isSystem = $me.IsSystem
Say ('User : {0}{1}' -f $me.Name, $(if ($isSystem) { ' (SYSTEM - sees W:, NOT the user''s S:)' } else { '' }))
Say ('Computer : {0}' -f $env:COMPUTERNAME)
if (-not $isSystem) {
Say 'Note : run this again as SYSTEM (psexec -s) to see what GE-Enforce sees.'
}
# ---------------------------------------------------------------------------
Section 'Name resolution and reachability'
# A dead mapping with a healthy UNC path is often just timing: if the server is
# unreachable at logon the persistent reconnect fails, and by the time anyone
# looks the network is fine.
foreach ($form in $forms) {
$ip = ''
try {
$ip = ([Net.Dns]::GetHostAddresses($form) |
Where-Object { $_.AddressFamily -eq 'InterNetwork' } |
Select-Object -First 1).IPAddressToString
} catch { $ip = 'DNS FAILED' }
$port = 'no'
try {
$client = New-Object Net.Sockets.TcpClient
$async = $client.BeginConnect($form, 445, $null, $null)
if ($async.AsyncWaitHandle.WaitOne(3000) -and $client.Connected) { $port = 'yes' }
$client.Close()
} catch { }
Say ('{0,-45} -> {1,-16} SMB/445 reachable: {2}' -f $form, $ip, $port)
if ($port -ne 'yes') {
$findings.Add("Cannot reach $form on 445 right now. If the mapping failed at logon for the same reason, that is cause 1 - a reconnect that raced the network.")
}
}
# ---------------------------------------------------------------------------
Section 'Drive mappings in THIS session'
# The 'user' column is the point: a drive remembering an account other than the
# one running this is what makes a credential prompt possible at all.
$netuse = & net use 2>&1 | Out-String
Say $netuse.TrimEnd()
foreach ($drive in $Drives) {
$line = ($netuse -split "`r?`n" | Where-Object { $_ -match [regex]::Escape($drive) } | Select-Object -First 1)
if (-not $line) {
Say ('{0} not present in this session.' -f $drive)
} elseif ($line -match 'Unavailable|Disconnected') {
Say ('{0} present but NOT connected.' -f $drive)
$findings.Add("$drive exists but is not connected - the classic dead persistent reconnect (cause 1).")
}
}
# ---------------------------------------------------------------------------
Section 'Stored credentials'
# cmdkey matches by STRING, so the exact Target text decides whether a mapping
# finds its account. This prints targets and usernames only - never a password,
# and cmdkey cannot reveal one anyway.
$cmdkey = & cmdkey /list 2>&1 | Out-String
$targets = [regex]::Matches($cmdkey, 'Target:\s*(\S+)') | ForEach-Object { $_.Groups[1].Value }
if ($targets) {
Say 'Targets stored for this user:'
foreach ($t in $targets) { Say (' {0}' -f $t) }
} else {
Say 'No stored credentials for this user.'
}
foreach ($form in $forms) {
$covered = @($targets | Where-Object {
$_ -match [regex]::Escape($form) -or
($_ -match '^\*' -and $form -like ($_ -replace '^Domain:target=', ''))
})
if ($covered.Count -eq 0) {
Say ('NO stored credential matches "{0}"' -f $form)
$findings.Add("No credential entry matches '$form'. If the mapping uses that form, it has no account to reconnect with (cause 2). Add an entry for every name form in use, or a wildcard.")
} else {
Say ('Credential covers "{0}": {1}' -f $form, ($covered -join ', '))
}
}
# ---------------------------------------------------------------------------
Section 'Kerberos tickets'
# A cifs/ ticket for the server means this identity can reach it on its own.
# On a shopfloor bay the logon usually CANNOT, which is why a stored account is
# mandatory - and that is what makes two-identities-one-server possible.
$klist = & klist 2>&1 | Out-String
$cifs = [regex]::Matches($klist, 'cifs/[^\s@]+') | ForEach-Object { $_.Value } | Select-Object -Unique
if ($cifs) { Say ('cifs tickets: {0}' -f ($cifs -join ', ')) } else { Say 'No cifs tickets for this identity.' }
# ---------------------------------------------------------------------------
Section 'Live SMB sessions to this server'
# ONE session per server per logon session. Two name forms count as two servers
# - which is how a single share ends up wanting two identities at once.
$connections = @()
try {
$connections = @(Get-SmbConnection -ErrorAction Stop |
Where-Object { $_.ServerName -match [regex]::Escape($short) })
} catch { }
if ($connections.Count) {
foreach ($c in $connections) {
Say ('{0,-45} share {1,-14} as {2}' -f $c.ServerName, $c.ShareName, $c.UserName)
}
$identities = @($connections | Select-Object -ExpandProperty UserName -Unique)
$servers = @($connections | Select-Object -ExpandProperty ServerName -Unique)
if ($identities.Count -gt 1) {
$findings.Add("More than one identity is connected to this server in this session ($($identities -join ', ')). That is cause 3, and whichever connected first wins - the other gets a credential prompt.")
}
if ($servers.Count -gt 1) {
$findings.Add("This server is connected under more than one NAME ($($servers -join ', ')). Windows treats those as different servers, so they get separate sessions and separate credential lookups.")
}
} else {
Say 'No live SMB session to this server in this logon session.'
}
# ---------------------------------------------------------------------------
Section 'Can this identity read the share right now'
# Test-Path is deliberately not trusted on its own: a SYSTEM process denied
# access to a UNC path gets a "not found", so a permissions problem reads as a
# missing directory.
foreach ($form in $forms) {
$path = '\\{0}\{1}' -f $form, $Share
$ok = $false
try { $ok = Test-Path -LiteralPath $path -ErrorAction Stop } catch { }
Say ('{0,-55} readable: {1}' -f $path, $ok)
if (-not $ok -and $isSystem) {
Say ' (as SYSTEM a denial reports as "not found" - do not read this as a missing path)'
}
}
# ---------------------------------------------------------------------------
Section 'SFLD credential provisioning (GE-Enforce)'
# Username and presence only. The password is never printed - it is a fleet
# credential and this log is written to a directory techs read.
$sfldBase = 'HKLM:\SOFTWARE\GE\SFLD\Credentials'
if (Test-Path $sfldBase) {
foreach ($key in Get-ChildItem $sfldBase -ErrorAction SilentlyContinue) {
$props = Get-ItemProperty -Path $key.PSPath -ErrorAction SilentlyContinue
$names = @($props.PSObject.Properties.Name)
$user = if ($names -contains 'Username') { $props.Username } else { '(none)' }
$hasPassword = ($names -contains 'Password') -and $props.Password
Say ('{0,-30} user {1,-28} password present: {2}' -f $key.PSChildName, $user, [bool]$hasPassword)
}
} else {
Say 'No SFLD credentials provisioned (Azure DSC has not run, or this is not a fleet PC).'
if ($isSystem) {
$findings.Add('No SFLD credential in the registry, so GE-Enforce cannot mount the share at all this cycle.')
}
}
# ---------------------------------------------------------------------------
Section 'Recent SMB client errors'
try {
$events = Get-WinEvent -FilterHashtable @{
LogName = 'Microsoft-Windows-SMBClient/Connectivity'; StartTime = (Get-Date).AddDays(-3)
} -MaxEvents 8 -ErrorAction Stop
foreach ($e in $events) {
Say ('{0} id {1} {2}' -f $e.TimeCreated.ToString('MM-dd HH:mm'), $e.Id,
(($e.Message -split "`r?`n")[0]))
}
} catch {
Say 'No SMBClient/Connectivity events in the last 3 days (or the log is not enabled).'
}
# ---------------------------------------------------------------------------
Section 'What this looks like'
if ($findings.Count -eq 0) {
Say 'Nothing conclusive. If the drive is failing only at logon, run this again'
Say 'IMMEDIATELY after a failure - several of these signals are transient and'
Say 'a working session masks the state that caused the prompt.'
} else {
$n = 0
foreach ($f in $findings) { $n++; Say ('{0}. {1}' -f $n, $f) }
}
Say ''
Say ('Full log: {0}' -f $logFile)
exit 0

View File

@@ -0,0 +1,37 @@
# ensure-vnc-firewall.ps1
# Idempotent inbound firewall rules for VNC port 5900 on all network profiles.
# Called by Install-FromManifest with Type=PS1, DetectionMethod=Always (runs
# every enforcement cycle; the Remove + New pattern makes repeat runs cheap
# and always end in a known-good state).
#
# Exit 0 on success, 1 on failure. SYSTEM context.
$ErrorActionPreference = 'Continue'
$rules = @(
@{ Name = 'GE Shopfloor VNC 5900 TCP'; Protocol = 'TCP'; LocalPort = 5900 }
@{ Name = 'GE Shopfloor VNC 5900 UDP'; Protocol = 'UDP'; LocalPort = 5900 }
)
$failed = 0
foreach ($r in $rules) {
try {
Remove-NetFirewallRule -DisplayName $r.Name -ErrorAction SilentlyContinue
New-NetFirewallRule `
-DisplayName $r.Name `
-Direction Inbound `
-Protocol $r.Protocol `
-LocalPort $r.LocalPort `
-Action Allow `
-Profile Domain,Private,Public `
-Description 'VNC remote access for shopfloor ops (5900). Managed by GE Shopfloor Enforce.' `
-ErrorAction Stop | Out-Null
Write-Host "[OK] $($r.Name) ($($r.Protocol) $($r.LocalPort))"
} catch {
Write-Host "[FAIL] $($r.Name): $_"
$failed++
}
}
if ($failed -gt 0) { exit 1 }
exit 0

View File

@@ -175,6 +175,45 @@ foreach ($dir in $pcdmisDirs) {
}
}
# ============================================================================
# Step 2.5b: GE PC-DMIS FRONT END dir (PCDToIGES.exe error-log path)
# ============================================================================
# PCDToIGES.exe (in every bay's C:\geaofi\Scripts) writes its error log to
# C:\GE PC-DMIS FRONT END\PCDToIGES.ERR inside its catch block. The legacy
# "GE PC-DMIS FRONT END" front-end setup that created this dir is not part of
# imaging, so on a fresh bay it is absent - and then ANY PCDToIGES failure
# becomes an unhandled DirectoryNotFoundException that crashes the export and
# MASKS the real error. Create it + grant the operator Modify so errors log.
$frontEnd = 'C:\GE PC-DMIS FRONT END'
try {
if (-not (Test-Path -LiteralPath $frontEnd)) { New-Item -ItemType Directory -Path $frontEnd -Force | Out-Null; Write-CMMLog "Created $frontEnd" }
foreach ($sid in '*S-1-5-32-545','*S-1-5-11') { # BUILTIN\Users, NT AUTHORITY\Authenticated Users
& icacls "$frontEnd" /grant "${sid}:(OI)(CI)M" /T /C 2>&1 | Out-Null
}
Write-CMMLog "Granted Users + Authenticated Users Modify on $frontEnd"
} catch { Write-CMMLog "Failed to ensure ${frontEnd}: $_" "WARN" }
# ============================================================================
# Step 2.5c: Active Setup - seed goCMM 2.12 DataFolder per-user for ALL users
# ============================================================================
# goCMM 2.12 stores its shared-data-dir in HKCU\Software\General Electric\goCMM
# \DataFolder (decompiled: RegistrySettings uses Registry.CurrentUser - PER USER,
# not HKLM). Setting it at imaging only touches the imaging user (SupportUser);
# the ShopFloor operator's HKCU would be empty -> goCMM prompts them to pick the
# folder on first launch. Active Setup runs StubPath once in EACH user's context
# at first logon, so every user (SupportUser, ShopFloor, future) gets
# DataFolder=C:\geaofi\. Bump Version to re-push. (Uses Set-ItemProperty in the
# stub, not reg.exe, to avoid the trailing-backslash quoting trap.)
$asKey = 'HKLM:\SOFTWARE\Microsoft\Active Setup\Installed Components\{8F3B2A10-7C4D-4E9A-B1F6-0A9C5D2E7B43}'
try {
New-Item -Path $asKey -Force | Out-Null
Set-ItemProperty -Path $asKey -Name '(default)' -Value 'goCMM DataFolder (per-user)'
Set-ItemProperty -Path $asKey -Name 'Version' -Value '1'
$stub = 'powershell.exe -NoProfile -WindowStyle Hidden -Command "New-Item -Path ''HKCU:\SOFTWARE\General Electric\goCMM'' -Force | Out-Null; Set-ItemProperty -Path ''HKCU:\SOFTWARE\General Electric\goCMM'' -Name DataFolder -Value ''C:\geaofi\''"'
Set-ItemProperty -Path $asKey -Name 'StubPath' -Value $stub
Write-CMMLog "Seeded Active Setup: goCMM DataFolder=C:\geaofi\ (per-user, all users)"
} catch { Write-CMMLog "Failed to seed Active Setup goCMM DataFolder: $_" 'WARN' }
# ============================================================================
# Step 2.6: First-run-as-admin for each installed PC-DMIS version
# ============================================================================
@@ -314,6 +353,28 @@ if (Test-Path -LiteralPath $restoreScript) {
Write-CMMLog "Restore-CMM.ps1 not found at $restoreScript - skipping settings restore" 'WARN'
}
# ============================================================================
# Step 2.5: drop Register-PCDMIS-COM.bat on the Public desktop
# ============================================================================
# PC-DMIS COM must be registered for goCMM to connect, but /regserver no-ops
# until PC-DMIS is licensed - which happens manually post-image (clmadmin.exe).
# So we cannot register at imaging time; instead leave a one-click helper the
# tech runs AFTER activating the license. Public Desktop so it is visible to
# whoever logs in (operator or SupportUser). NOTE: 06-OrganizeDesktop sweeps the
# Public Desktop root into category subfolders, so it may end up under
# "Shopfloor Tools" rather than the bare desktop - still on the Public desktop.
$regBat = Join-Path $stagingRoot 'Register-PCDMIS-COM.bat'
if (Test-Path -LiteralPath $regBat) {
$d = 'C:\Users\Public\Desktop'
try {
if (-not (Test-Path $d)) { New-Item -Path $d -ItemType Directory -Force | Out-Null }
Copy-Item -LiteralPath $regBat -Destination (Join-Path $d 'Register-PCDMIS-COM.bat') -Force
Write-CMMLog "Staged Register-PCDMIS-COM.bat -> $d"
} catch { Write-CMMLog "Failed to stage Register-PCDMIS-COM.bat to ${d}: $_" 'WARN' }
} else {
Write-CMMLog "Register-PCDMIS-COM.bat not in staging ($regBat) - desktop helper NOT placed" 'WARN'
}
# ============================================================================
# Step 3: Conditional cleanup of the bootstrap staging dir
# ============================================================================

View File

@@ -1,48 +1,165 @@
# Install-DODA.ps1 - Extract DODA zip to C:\Apps\DODA\.
# Install-DODA.ps1 - full unattended DODA install (runtimes + app + config + ACLs).
#
# Called by Install-FromManifest as a Type=PS1 entry. The zip is staged
# alongside this script in C:\CMM-Install\ by startnet.cmd.
# The vendor GE_DODA_build5_Installer.exe is a multi-app NSIS bundler. Its /S
# silent mode is BROKEN (a nested sub-install gets a bad package path -> MSI
# error 1619), so it cannot be used for imaging. Each bundled component installs
# cleanly on its own, so we drive them directly here. (VM-validated 2026-06-17.)
#
# Components staged in C:\CMM-Install (robocopied from the SFLD/enrollment share
# by startnet.cmd), all installed SILENTLY:
# python-2.7.16.amd64.msi -> C:\Python27 (msiexec /qn)
# java-1.8.0-openjdk-*.msi -> C:\Program Files\RedHat (msiexec /qn)
# gs927w64.exe -> C:\Program Files\gs\gs9.27 (/S)
# vc_redist.x64.exe -> VC++ runtime (/quiet)
# doda_build*.zip -> C:\Apps\DODA (app files)
#
# DODA at runtime is DovetailAnalysis.exe -> JVM (OpenJDK) + C:\Python27 python +
# Ghostscript (PS->PDF). .settings (NOT in the zip - the NSIS installer wrote it)
# hardcodes those paths; we write it here.
#
# LOCKDOWN: DovetailAnalysis.exe runs as the locked-down OPERATOR (not admin) and
# writes throughout C:\Apps\DODA (.pyc, PreProcess) + $TMP. We (a) grant Users +
# Authenticated Users Full on C:\Apps\DODA and (b) PRECOMPILE the .py to .pyc as
# admin so the operator never needs to write bytecode into a protected path.
#
# Run as administrator / SYSTEM (imaging or GE-Enforce). Idempotent: each step
# detects-and-skips if already present, and ACL/.settings/env re-assert every run
# (so a post-lockdown re-run repairs a stripped ACE).
$ErrorActionPreference = 'Continue'
# The doda zip + runtime installers are staged ALONGSIDE this script (same dir).
# When the manifest runs it, that dir is C:\CMM-Install (= $PSScriptRoot).
$stagingRoot = $PSScriptRoot
if (-not $stagingRoot -or -not (Test-Path $stagingRoot)) { $stagingRoot = 'C:\CMM-Install' }
$installDir = 'C:\Apps\DODA'
$zipPattern = 'doda_build*.zip'
$stagingRoot = Split-Path $PSScriptRoot -ErrorAction SilentlyContinue
if (-not $stagingRoot) { $stagingRoot = 'C:\CMM-Install' }
$python27 = 'C:\Python27\python.exe'
$gsExe = 'C:\Program Files\gs\gs9.27\bin\gswin64c.exe'
$zip = Get-ChildItem -Path $stagingRoot -Filter $zipPattern -File -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $zip) {
Write-Host "DODA zip not found in $stagingRoot (pattern: $zipPattern)"
exit 1
$logDir = 'C:\Logs\CMM'
New-Item -ItemType Directory -Path $logDir -Force -EA SilentlyContinue | Out-Null
$ts = Get-Date -Format 'yyyyMMdd-HHmmss'
$log = Join-Path $logDir "doda-install-$ts.log"
function Log($m){ $line = "[{0}] {1}" -f (Get-Date -Format 'HH:mm:ss'), $m; Write-Host $line; Add-Content -Path $log -Value $line -EA SilentlyContinue }
function Find-One($pattern) {
Get-ChildItem -Path $stagingRoot -Filter $pattern -File -EA SilentlyContinue | Select-Object -First 1
}
if (-not (Test-Path $installDir)) {
New-Item -Path $installDir -ItemType Directory -Force | Out-Null
}
Log "==== DODA install on $env:COMPUTERNAME (staging=$stagingRoot) ===="
Write-Host "Extracting $($zip.Name) to $installDir..."
try {
Expand-Archive -LiteralPath $zip.FullName -DestinationPath $installDir -Force -ErrorAction Stop
Write-Host "DODA extracted to $installDir"
} catch {
Write-Host "ERROR: Extract failed - $_"
exit 1
}
# MergeFiles.exe (cmm-utilities toolchain) reads C:\Apps\DODA\PreProcess\ as
# its working dir. The DODA zip extracts flat without it, so create it here -
# a missing PreProcess dir is the known cause of MergeFiles.GetDoDAFolder
# throwing DirectoryNotFoundException (see cmm-utilities dotNET event.txt).
$preProcess = Join-Path $installDir 'PreProcess'
if (-not (Test-Path $preProcess)) {
New-Item -Path $preProcess -ItemType Directory -Force | Out-Null
Write-Host "Created $preProcess"
}
if (Test-Path (Join-Path $installDir 'DovetailAnalysis.exe')) {
Write-Host "DovetailAnalysis.exe verified present"
exit 0
# --- 1. Python 2.7 ----------------------------------------------------------
if (Test-Path $python27) {
Log "Python 2.7 already present - skip"
} else {
Write-Host "ERROR: DovetailAnalysis.exe not found after extract"
$msi = Find-One 'python-2.7*.msi'
if ($msi) {
Log "Installing Python: $($msi.Name)"
$p = Start-Process msiexec.exe -Wait -PassThru -ArgumentList @('/i', "`"$($msi.FullName)`"", '/qn', '/norestart', 'ADDLOCAL=ALL')
Log " Python msiexec exit $($p.ExitCode)"
} else { Log "WARN: python-2.7*.msi not found in $stagingRoot" }
}
# --- 2. OpenJDK 8 -----------------------------------------------------------
$jdk = Get-ChildItem 'C:\Program Files\RedHat','C:\Program Files\Java' -Directory -EA SilentlyContinue |
Where-Object Name -match 'openjdk|jdk|jre' | Select-Object -First 1
if ($jdk) {
Log "OpenJDK already present ($($jdk.Name)) - skip"
} else {
$msi = Find-One 'java-*openjdk*.msi'
if ($msi) {
Log "Installing OpenJDK: $($msi.Name)"
$p = Start-Process msiexec.exe -Wait -PassThru -ArgumentList @('/i', "`"$($msi.FullName)`"", '/qn', '/norestart')
Log " OpenJDK msiexec exit $($p.ExitCode)"
$jdk = Get-ChildItem 'C:\Program Files\RedHat','C:\Program Files\Java' -Directory -EA SilentlyContinue |
Where-Object Name -match 'openjdk|jdk|jre' | Select-Object -First 1
} else { Log "WARN: java-*openjdk*.msi not found in $stagingRoot" }
}
# --- 3. Ghostscript 9.27 ----------------------------------------------------
if (Test-Path $gsExe) {
Log "Ghostscript 9.27 already present - skip"
} else {
$gs = Find-One 'gs927w64.exe'
if ($gs) {
Log "Installing Ghostscript: $($gs.Name)"
$p = Start-Process $gs.FullName -Wait -PassThru -ArgumentList '/S'
Log " Ghostscript exit $($p.ExitCode)"
} else { Log "WARN: gs927w64.exe not found in $stagingRoot" }
}
# --- 4. VC++ redist (best-effort) ------------------------------------------
$vc = Find-One 'vc_redist.x64.exe'
if ($vc) {
Log "Installing VC++ redist: $($vc.Name)"
$p = Start-Process $vc.FullName -Wait -PassThru -ArgumentList @('/quiet', '/norestart')
Log " VC++ exit $($p.ExitCode) (1638/3010 = already-present/ok)"
} else { Log "VC++ redist not staged - skipping (usually already present)" }
# --- 5. DODA app files ------------------------------------------------------
$zip = Find-One 'doda_build*.zip'
if (-not $zip) {
Log "ERROR: doda_build*.zip not found in $stagingRoot - cannot place DODA app"
} else {
New-Item -ItemType Directory -Path $installDir -Force -EA SilentlyContinue | Out-Null
Log "Extracting $($zip.Name) -> $installDir"
try { Expand-Archive -LiteralPath $zip.FullName -DestinationPath $installDir -Force -EA Stop }
catch { Log "ERROR: extract failed - $_" }
}
# --- 6. .settings (the NSIS installer wrote this; the zip does NOT contain it) ---
$settings = Join-Path $installDir '.settings'
$settingsBody = @'
WORKING_DIR=$TMP
PYTHONEXE=C:\Python27\python.exe
OPTIONS_FILE="$WORKING_DIR/dvt_run_options.py"
RPT_GUI=$PYTHONEXE $AFDA_INSTALL_DIR/dvtRptGui.py -file=$OPTIONS_FILE
OUTPUT_PROCESSOR=$PYTHONEXE $AFDA_INSTALL_DIR/processOutput.py $OPTIONS_FILE
PS2PDF="C:\Program Files\gs\gs9.27\bin\gswin64c.exe" -q -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile=$$out $$in
'@
if (Test-Path $installDir) {
Set-Content -Path $settings -Value $settingsBody -Encoding ascii -Force
Log "Wrote $settings"
}
# --- 7. PreProcess dir (MergeFiles GetDoDAFolder reads it) ------------------
$preProcess = Join-Path $installDir 'PreProcess'
if (-not (Test-Path $preProcess)) { New-Item -ItemType Directory -Path $preProcess -Force | Out-Null; Log "Created $preProcess" }
# --- 8. Env: AFDA_INSTALL_DIR + Java on PATH (machine scope) ----------------
[Environment]::SetEnvironmentVariable('AFDA_INSTALL_DIR', $installDir, 'Machine')
Log "Set AFDA_INSTALL_DIR=$installDir (Machine)"
if ($jdk) {
$machPath = [Environment]::GetEnvironmentVariable('PATH','Machine')
foreach ($add in @((Join-Path $jdk.FullName 'bin'), (Join-Path $jdk.FullName 'jre\bin'))) {
if (Test-Path $add) {
$parts = $machPath -split ';'
if ($parts -notcontains $add) { $machPath = "$machPath;$add"; Log "PATH += $add" }
}
}
[Environment]::SetEnvironmentVariable('PATH', $machPath, 'Machine')
}
# --- 9. Precompile .py -> .pyc as admin (so the locked-down operator never
# needs write access to compile bytecode at runtime) ------------------
if ((Test-Path $python27) -and (Test-Path $installDir)) {
Log "Precompiling DODA .py with $python27"
$p = Start-Process $python27 -Wait -PassThru -ArgumentList @('-m','compileall', "`"$installDir`"") -WindowStyle Hidden
Log " compileall exit $($p.ExitCode)"
}
# --- 10. ACLs: Users + Authenticated Users Full on C:\Apps\DODA so the
# locked-down operator can run DODA (writes .pyc/PreProcess/output) ---
if (Test-Path $installDir) {
foreach ($sid in '*S-1-5-32-545','*S-1-5-11') { # BUILTIN\Users, NT AUTHORITY\Authenticated Users
& icacls $installDir /grant "${sid}:(OI)(CI)F" /T /C 2>&1 | Out-Null
}
Log "Granted Users + Authenticated Users Full on $installDir (recursive)"
}
# --- Verify -----------------------------------------------------------------
$ok = (Test-Path (Join-Path $installDir 'DovetailAnalysis.exe')) -and (Test-Path $python27) -and (Test-Path $gsExe) -and $jdk
if ($ok) { Log "==== DODA install OK (DovetailAnalysis + Python27 + Ghostscript + OpenJDK present) ===="; exit 0 }
else {
Log "==== DODA install INCOMPLETE - DovetailAnalysis=$([bool](Test-Path (Join-Path $installDir 'DovetailAnalysis.exe'))) Python=$([bool](Test-Path $python27)) GS=$([bool](Test-Path $gsExe)) JDK=$([bool]$jdk) ===="
exit 1
}

View File

@@ -0,0 +1,57 @@
@echo off
REM ============================================================================
REM Register-PCDMIS-COM.bat - register the PC-DMIS COM / automation server.
REM
REM WHY: goCMM connects to PC-DMIS over COM. If the COM server is not
REM registered, goCMM throws "Object reference not set to an instance of an
REM object" at startup (it calls Type.GetTypeFromProgID on the PC-DMIS ProgID,
REM gets null, then Activator.CreateInstance(null) -> ArgumentNullException).
REM
REM IMPORTANT: PC-DMIS must be LICENSED FIRST. /regserver does NOT register the
REM COM server until a valid license is present. Activate the license with
REM clmadmin.exe, THEN run this. Safe to re-run any time goCMM stops connecting.
REM
REM Needs admin - this script self-elevates via UAC.
REM ============================================================================
REM --- self-elevate if not already running as admin ---
net session >nul 2>&1
if %errorlevel% neq 0 (
echo Requesting administrator rights...
powershell -NoProfile -Command "Start-Process -FilePath '%~f0' -Verb RunAs"
exit /b
)
echo ================================================================
echo Register PC-DMIS COM server (run AFTER licensing PC-DMIS)
echo ================================================================
echo.
setlocal enabledelayedexpansion
set "FOUND="
for %%D in (
"C:\Program Files\Hexagon"
"C:\Program Files (x86)\Hexagon"
"C:\Program Files\WAI"
"C:\Program Files (x86)\WAI"
) do (
if exist %%~D (
for /f "delims=" %%F in ('dir /b /s "%%~D\Pcdlrn.exe" 2^>nul') do (
set "FOUND=1"
echo Registering: %%F
"%%F" /regserver
if !errorlevel! equ 0 (echo OK) else (echo regserver returned !errorlevel!)
)
)
)
echo.
if not defined FOUND (
echo ERROR: Pcdlrn.exe not found. Is PC-DMIS installed?
) else (
echo Done.
echo If goCMM still errors, confirm PC-DMIS is LICENSED ^(clmadmin.exe^) and
echo that you ran this AFTER activating the license, then re-run.
)
echo.
pause

View File

@@ -2,12 +2,12 @@ cmm_id,pcdmis_version,doda,part_group
CMM1,2019,no,S:\CMM\CMM1\HPTCMM1
CMM2,2019,no,S:\CMM\CMM2\HPT
CMM3,2019,no,S:\CMM\CMM3\VENTURE_CMM3
CMM4,2016,no,S:\CMM\CMM4\Spool
CMM4,2016,yes,S:\CMM\CMM4\Spool
CMM5,2019,no,S:\CMM\CMM5\BLISKCMM5
CMM6,2019,no,S:\CMM\CMM6\BLISKCMM6
CMM7,2019,no,S:\CMM\CMM7\VENTURE_CMM7
CMM8,2019,no,S:\CMM\CMM8\Venture CMM8
CMM9,2019,no,S:\CMM\CMM9\BLISKCMM9
CMM10,2016,no,S:\CMM\CMM10\Spool
CMM11,2026,no,S:\CMM\CMM11\Spool
CMM12,2026,no,S:\CMM\CMM12\Spool
CMM10,2016,yes,S:\CMM\CMM10\Spool
CMM11,2026,yes,S:\CMM\CMM11\Spool
CMM12,2026,yes,S:\CMM\CMM12\Spool
1 cmm_id pcdmis_version doda part_group
2 CMM1 2019 no S:\CMM\CMM1\HPTCMM1
3 CMM2 2019 no S:\CMM\CMM2\HPT
4 CMM3 2019 no S:\CMM\CMM3\VENTURE_CMM3
5 CMM4 2016 no yes S:\CMM\CMM4\Spool
6 CMM5 2019 no S:\CMM\CMM5\BLISKCMM5
7 CMM6 2019 no S:\CMM\CMM6\BLISKCMM6
8 CMM7 2019 no S:\CMM\CMM7\VENTURE_CMM7
9 CMM8 2019 no S:\CMM\CMM8\Venture CMM8
10 CMM9 2019 no S:\CMM\CMM9\BLISKCMM9
11 CMM10 2016 no yes S:\CMM\CMM10\Spool
12 CMM11 2026 no yes S:\CMM\CMM11\Spool
13 CMM12 2026 no yes S:\CMM\CMM12\Spool

View File

@@ -57,19 +57,18 @@
"DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{a55fecde-0776-474e-a5b3-d57ea93d6a9f}"
},
{
"_comment": "goCMM - Hexagon CMM job launcher. Install for all versions.",
"Name": "goCMM",
"Installer": "goCMM_1.1.6718.31289.exe",
"_comment": "goCMM 2.12.3 - Hexagon/Renishaw CMM job launcher. NSIS installer; silent switch is /S (NOT /quiet - that was the WiX 1.1 bundle). Installs to C:\\Program Files\\goCMM and runs no-admin (asInvoker, VM-verified). This is the IMAGING manifest only, so NEW images get 2.12; the tsgwp00525 SFLD enforce manifest stays on goCMM 1.1 so already-deployed bays are NOT auto-upgraded. Install for all versions.",
"Name": "goCMM 2.12.3",
"Installer": "goCMM_2.12.3.0_Release.exe",
"Type": "EXE",
"InstallArgs": "/quiet /norestart /log \"C:\\Logs\\CMM\\goCMM.log\"",
"LogFile": "C:\\Logs\\CMM\\goCMM.log",
"DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{94f02b85-bbca-422e-9b8b-0c16a769eced}"
"InstallArgs": "/S",
"DetectionMethod": "File",
"DetectionPath": "C:\\Program Files\\goCMM\\goCMM.exe"
},
{
"_comment": "DODA - Dovetail Digital Analysis. Deployed as a flat file extract to C:\\Apps\\DODA\\. Only installed when doda.txt=yes (pc-subtype.txt=doda gates this via PCTypes).",
"_comment": "DODA - Dovetail Digital Analysis. Deployed as a flat file extract to C:\\Apps\\DODA\\. Only installed when doda.txt=yes -> startnet writes pc-subtype.txt=doda -> 09-Setup passes PCType=gea-shopfloor-cmm + PCSubType=doda -> matcher builds 'gea-shopfloor-cmm-doda'. PCTypes MUST be that exact Type-SubType string ('cmm-doda' never matched - the alias of gea-shopfloor-cmm is bare 'CMM', no subtype variant).",
"Name": "DODA",
"PCTypes": ["cmm-doda"],
"PCTypes": ["gea-shopfloor-cmm-doda"],
"Type": "PS1",
"Script": "Install-DODA.ps1",
"DetectionMethod": "File",

View File

@@ -63,6 +63,30 @@ foreach ($s in 'Backup-goCMMSettings.ps1','Backup-PCDMISSettings.ps1') {
catch { Log " ERROR in $s : $($_.Exception.Message)" }
}
# --- C:\Program Files\WAI (+ x86): whole vendor tree, captured complete.
# Holds machine/controller content beyond what the per-version PC-DMIS backup
# grabs. NOTE: can be multi-GB if WAI contains the full PC-DMIS 2016 install. ---
$waiRoots = @("$env:ProgramFiles\WAI", "${env:ProgramFiles(x86)}\WAI")
$waiPresent = @($waiRoots | Where-Object { Test-Path $_ })
if ($waiPresent.Count -gt 0) {
Log "---- capturing WAI tree (whole) ----"
$waiStage = Join-Path $env:TEMP "wai-bk-$ts"
New-Item -ItemType Directory -Path $waiStage -Force | Out-Null
foreach ($wr in $waiPresent) {
$label = if ($wr -like '*(x86)*') { 'WAI-x86' } else { 'WAI' }
robocopy $wr (Join-Path $waiStage $label) /E /R:1 /W:1 /NFL /NDL /NJH /NJS | Out-Null
Log " copied $wr -> $label"
}
$waiZip = Join-Path $dest "wai_backup_${env:COMPUTERNAME}_$ts.zip"
if (Test-Path $waiZip) { Remove-Item $waiZip -Force }
Add-Type -AssemblyName System.IO.Compression.FileSystem
[System.IO.Compression.ZipFile]::CreateFromDirectory($waiStage, $waiZip)
Remove-Item $waiStage -Recurse -Force -EA SilentlyContinue
Log " WAI backup: $waiZip ($([math]::Round((Get-Item $waiZip).Length/1MB)) MB)"
} else {
Log "No C:\Program Files\WAI (or x86) - skipping WAI backup"
}
# index of what we captured
$zips = Get-ChildItem $dest -Filter '*.zip' -File -ErrorAction SilentlyContinue
[pscustomobject]@{
@@ -71,6 +95,7 @@ $zips = Get-ChildItem $dest -Filter '*.zip' -File -ErrorAction SilentlyContinue
Timestamp = (Get-Date -Format o)
goCMM = @($zips | Where-Object { $_.Name -like 'gocmm_backup_*' } | Select-Object -Expand Name)
PCDMIS = @($zips | Where-Object { $_.Name -like 'pcdmis_backup_*' } | Select-Object -Expand Name)
WAI = @($zips | Where-Object { $_.Name -like 'wai_backup_*' } | Select-Object -Expand Name)
} | ConvertTo-Json | Out-File (Join-Path $dest 'cmm-backup-index.json') -Encoding ascii
Log "================ DONE ================"

View File

@@ -0,0 +1,195 @@
<#
Convert-goCMMSettings.ps1
Convert a legacy goCMM 1.1 ApplicationSettings.xml (C:\geaofi\) to the goCMM
2.12.3 goCMMSettings.xml schema.
The two are different SettingsModel schemas (different filename too). This is a
TEMPLATE-based converter: new-only fields get defaults, the per-bay values are
carried over from the old XML (+ optional overrides). Decisions baked in
(2026-06-19): access-control left blank, operator key normalized to "Operator",
cal intervals/restart use template defaults (old values NOT carried), Slack/Andon
/Quindos/Emx dropped.
Program paths default to the goCMM 2.12 convention (C:\geaofi\goCMM PC-DMIS
Programs\) - override with -ResetProgram / -ProbeCalibrationProgram if the .PRG
files live elsewhere. Those .PRG files MUST exist at the path written here.
Usage:
.\Convert-goCMMSettings.ps1 -OldXml C:\geaofi\ApplicationSettings.xml `
-OutFile C:\geaofi\goCMMSettings.xml -PcdmisVersion 2016 -CmmId CMM10
#>
param(
[Parameter(Mandatory=$true)][string]$OldXml,
[Parameter(Mandatory=$true)][string]$OutFile,
[string]$PcdmisVersion, # 2016 | 2019 | 2026 (or full "PC-DMIS ... 64-bit")
[string]$CmmId, # e.g. CMM10 - used for the cal-program name default
[string]$PartGroup, # optional override (friendly S:\ or UNC); else from old XML
[string]$ResetProgram, # optional override
[string]$ProbeCalibrationProgram # optional override
)
$ErrorActionPreference = 'Stop'
if (-not (Test-Path -LiteralPath $OldXml)) { throw "Old XML not found: $OldXml" }
[xml]$old = Get-Content -LiteralPath $OldXml -Raw
# --- part-group share canonicalization (FQDN + \SHARED, matches goCMM 2.12) ---
function Canon([string]$pg) {
if (-not $pg) { return $pg }
$pg = $pg -replace '(?i)^S:\\', '\\tsgwp00525.wjs.geaerospace.net\SHARED\'
$pg = [regex]::Replace($pg, '(?i)\\\\tsgwp00525(?:\.[A-Za-z0-9.\-]+)?\\shared(?=\\|$)', '\\tsgwp00525.wjs.geaerospace.net\SHARED')
return $pg
}
# --- resolve per-bay values (override -> old XML -> default) ---
# NOTE: read attributes with GetAttribute() - .Name collides with XmlElement.Name.
$pg = $PartGroup
if (-not $pg) {
$pgNode = $old.SettingsModel.PartGroups.PartGroup
if ($pgNode) { $pg = $pgNode.GetAttribute('FullName'); if (-not $pg) { $pg = $pgNode.FullName } }
}
$pg = Canon $pg
# NB: PowerShell vars are case-insensitive - do NOT name this $cmmid (collides with the $CmmId param).
$machineCmmId = $old.SettingsModel.MachineDefinition.CMMID
if (-not $machineCmmId -and $CmmId) { $machineCmmId = "WJRP/$CmmId" }
# SelectedVersionString from -PcdmisVersion
$verMap = @{ '2016' = 'PC-DMIS 2016.0 64-bit'; '2019' = 'PC-DMIS 2019 R2 64-bit'; '2026' = 'PC-DMIS 2026.1 64-bit' }
$selVer = ''
if ($PcdmisVersion) { $selVer = if ($verMap.ContainsKey($PcdmisVersion)) { $verMap[$PcdmisVersion] } else { $PcdmisVersion } }
# bool carryovers (old flat flags -> new IsEnabled)
function OldBool($node, $default='false') { $v = $node; if ($null -eq $v -or $v -eq '') { $default } else { ([string]$v).ToLower() } }
$pcdmisEnabled = OldBool $old.SettingsModel.UsingPcDmis 'true'
$modusEnabled = OldBool $old.SettingsModel.UsingModus 'false'
$winState = if ($old.SettingsModel.DefaultWindowState) { $old.SettingsModel.DefaultWindowState } else { 'Maximized' }
$allowSel = OldBool $old.SettingsModel.AllowOperationProgramSelection 'true'
$visExec = OldBool $old.SettingsModel.PcDmisSettings.VisibleDuringExecution 'true'
$cmmMode = if ($old.SettingsModel.PcDmisSettings.CMMMode) { $old.SettingsModel.PcDmisSettings.CMMMode } else { 'Online' }
$sizeTol = if ($old.SettingsModel.MachineDefinition.ProbeCalSizeTol) { $old.SettingsModel.MachineDefinition.ProbeCalSizeTol } else { '0.0005' }
$formTol = if ($old.SettingsModel.MachineDefinition.ProbeCalFormTol) { $old.SettingsModel.MachineDefinition.ProbeCalFormTol } else { '0.0005' }
$hasRotary = OldBool $old.SettingsModel.MachineDefinition.MachineHasRotaryTable 'false'
# program paths (override -> goCMM 2.12 convention default)
$progDir = 'C:\geaofi\goCMM PC-DMIS Programs'
if (-not $ResetProgram) { $ResetProgram = "$progDir\MachineResetProgram.prg" }
if (-not $ProbeCalibrationProgram) {
$calName = if ($CmmId) { "${CmmId}_Cal_All_Probes_rev2.PRG" } else { 'Cal_All_Probes_rev2.PRG' }
$ProbeCalibrationProgram = "$progDir\$calName"
}
# --- operator inputs (old KeyLabel + NonTBIInputs -> new OperatorInput; OPER -> Operator) ---
$inputs = @()
$kl = $old.SettingsModel.OperatorInputs.KeyLabel
if ($kl) { $inputs += [pscustomobject]@{ Key = $kl.GetAttribute('Name'); Display = $kl.GetAttribute('Display') } }
foreach ($oi in $old.SettingsModel.OperatorInputs.NonTBIInputs.OperatorInput) {
if ($oi -is [System.Xml.XmlElement]) { $inputs += [pscustomobject]@{ Key = $oi.GetAttribute('Name'); Display = $oi.GetAttribute('Display') } }
}
if (-not $inputs) {
$inputs = @([pscustomobject]@{Key='SERNO';Display='Serial Number'}, [pscustomobject]@{Key='Operator';Display='Operator'})
}
$opXml = ($inputs | ForEach-Object {
$k = if ($_.Key -ieq 'OPER') { 'Operator' } else { $_.Key } # decision #4
$d = if ($_.Key -ieq 'OPER') { 'Operator' } else { $_.Display }
" <OperatorInput ClearInput=`"true`">`n <Key>$k</Key>`n <DisplayName>$d</DisplayName>`n <ValidationRules />`n </OperatorInput>"
}) -join "`n"
function X([string]$s){ [System.Security.SecurityElement]::Escape($s) }
$xml = @"
<?xml version="1.0" encoding="utf-8"?>
<SettingsModel xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" LockInterface="false" AdminGroupName="" PowerUsersGroupName="" PasswordHash="" PasswordSalt="" LockMachineReset="false">
<MaintainFolderStructureWhenCopying>true</MaintainFolderStructureWhenCopying>
<DefaultSoftware>PCDMIS</DefaultSoftware>
<PartGroups>
<PartGroup>
<FullName>$(X $pg)</FullName>
<Children />
</PartGroup>
</PartGroups>
<OperatorInputs>
$opXml
</OperatorInputs>
<UseBladeGrid>false</UseBladeGrid>
<DefaultWindowState>$(X $winState)</DefaultWindowState>
<AllowOperationProgramSelection>$allowSel</AllowOperationProgramSelection>
<BladesDatFile />
<PcDmisSettings>
<IsEnabled>$pcdmisEnabled</IsEnabled>
<VisibleDuringExecution>$visExec</VisibleDuringExecution>
<ResetProgram>$(X $ResetProgram)</ResetProgram>
<RotaryTableCalibrationProgram />
<TrackProbeCalibration>false</TrackProbeCalibration>
<TrackRotaryTableCalbiration>false</TrackRotaryTableCalbiration>
<ProbeCalibrationProgram>$(X $ProbeCalibrationProgram)</ProbeCalibrationProgram>
<RotaryTableCalibration />
<IPPServerExe />
<IPPArguments />
<SaveAndClose>false</SaveAndClose>
<DelayMeasurementRoutines>false</DelayMeasurementRoutines>
<MeasurementRoutineDelay>1000</MeasurementRoutineDelay>
<Restart>true</Restart>
<RestartMode>ProgramCount</RestartMode>
<UseIPPServer>false</UseIPPServer>
<SelectedVersionString>$(X $selVer)</SelectedVersionString>
<NumberOfProgramsBeforeRestart>5</NumberOfProgramsBeforeRestart>
<RestartDelay>5</RestartDelay>
<StartupDelay>0</StartupDelay>
<UseOtherSoftwareForRotaryCal>false</UseOtherSoftwareForRotaryCal>
<UseOtherSoftwareForProbeCalibration>false</UseOtherSoftwareForProbeCalibration>
<RotaryCalSoftware />
<ProbeCalSoftware />
</PcDmisSettings>
<ModusSettings>
<IsEnabled>$modusEnabled</IsEnabled>
<VisibleDuringExecution>true</VisibleDuringExecution>
<ResetProgram />
<RotaryTableCalibrationProgram />
<TrackProbeCalibration>false</TrackProbeCalibration>
<TrackRotaryTableCalbiration>false</TrackRotaryTableCalbiration>
<ProbeCalibrationProgram />
<RotaryTableCalibration />
<IPPServerExe />
<IPPArguments />
<ModusArguments>-Organiser</ModusArguments>
</ModusSettings>
<MachineDefinition>
<CMMID>$(X $machineCmmId)</CMMID>
<MachineType />
<HeadType />
<ForceProbeCalibration>false</ForceProbeCalibration>
<ProbeCalSizeTol>$sizeTol</ProbeCalSizeTol>
<ProbeCalFormTolerance>$formTol</ProbeCalFormTolerance>
<ProbeCalibrationInterval>
<Hours>72</Hours>
<Minutes>0</Minutes>
</ProbeCalibrationInterval>
<RotaryTableCalibrationInterval>
<Hours>24</Hours>
<Minutes>0</Minutes>
</RotaryTableCalibrationInterval>
<MachineHasRotary>$hasRotary</MachineHasRotary>
<ForceRotaryCalibration>false</ForceRotaryCalibration>
<CMMMode>$(X $cmmMode)</CMMMode>
</MachineDefinition>
<Notifications UseAndon="false">
<Notifications />
</Notifications>
<ProgramConstants />
<CommonFiles />
</SettingsModel>
"@
$dir = Split-Path -Parent $OutFile
if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
# validate well-formed before writing
[void][xml]$xml
Set-Content -LiteralPath $OutFile -Value $xml -Encoding UTF8
Write-Host "Wrote $OutFile"
Write-Host " PartGroup = $pg"
Write-Host " CMMID = $machineCmmId"
Write-Host " SelectedVersion = $selVer"
Write-Host " ResetProgram = $ResetProgram"
Write-Host " ProbeCalProgram = $ProbeCalibrationProgram"
Write-Host "NOTE: confirm those .PRG files exist at the paths above (-ResetProgram / -ProbeCalibrationProgram to override)."

View File

@@ -0,0 +1,21 @@
@echo off
REM Ensure-PCDMISFrontEnd.bat - launcher for Ensure-PCDMISFrontEnd.ps1
REM
REM Creates C:\GE PC-DMIS FRONT END + grants the operator write access, so
REM PCDToIGES.exe can write its error log there instead of crashing with
REM "Could not find a part of the path 'C:\GE PC-DMIS FRONT END\PCDToIGES.ERR'".
REM
REM Needs admin - self-elevates.
net session >nul 2>&1
if %errorlevel% neq 0 (
echo Requesting administrator rights...
powershell -NoProfile -Command "Start-Process -FilePath '%~f0' -Verb RunAs"
exit /b
)
set "HERE=%~dp0"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%HERE%Ensure-PCDMISFrontEnd.ps1"
echo.
pause

View File

@@ -0,0 +1,47 @@
# Ensure-PCDMISFrontEnd.ps1 - create C:\GE PC-DMIS FRONT END + grant operator write.
#
# WHY: PCDToIGES.exe (geaofi\Scripts) writes its error log to
# C:\GE PC-DMIS FRONT END\PCDToIGES.ERR in its catch block. If that dir does
# not exist, ANY PCDToIGES failure becomes an unhandled DirectoryNotFoundException
# that CRASHES the export and MASKS the real error (observed on freshly imaged CMM
# bays - the legacy "GE PC-DMIS FRONT END" front-end setup is not part of imaging,
# so the dir is absent). Creating it + making it operator-writable lets the real
# error log instead of crashing, and gives the front-end a working dir.
#
# Run as administrator. Idempotent - safe to re-run; re-asserts the ACL.
$ErrorActionPreference = 'Continue'
$dir = 'C:\GE PC-DMIS FRONT END'
$logDir = 'C:\Logs\CMM'
New-Item -ItemType Directory -Path $logDir -Force -EA SilentlyContinue | Out-Null
$ts = Get-Date -Format 'yyyyMMdd-HHmmss'
$log = Join-Path $logDir "pcdmis-frontend-$ts.log"
function Log($m){ $line = "[{0}] {1}" -f (Get-Date -Format 'HH:mm:ss'), $m; Write-Host $line; Add-Content -Path $log -Value $line -EA SilentlyContinue }
Log "==== Ensure-PCDMISFrontEnd on $env:COMPUTERNAME ===="
if (Test-Path -LiteralPath $dir) {
Log "Dir already exists: $dir"
} else {
try { New-Item -ItemType Directory -Path $dir -Force -EA Stop | Out-Null; Log "Created $dir" }
catch { Log "ERROR: could not create $dir - $($_.Exception.Message)"; exit 1 }
}
# Operator (locked-down, non-admin) must be able to write PCDToIGES.ERR here.
# Modify = read/write/create/delete. SIDs, not names, to stay locale-independent.
foreach ($sid in '*S-1-5-32-545','*S-1-5-11') { # BUILTIN\Users, NT AUTHORITY\Authenticated Users
& icacls "$dir" /grant "${sid}:(OI)(CI)M" /T /C 2>&1 | Out-Null
}
Log "Granted Users + Authenticated Users Modify on $dir"
# Verify an operator-style write works
$probe = Join-Path $dir '.write-test'
try { Set-Content -LiteralPath $probe -Value 'ok' -EA Stop; Remove-Item -LiteralPath $probe -EA SilentlyContinue; Log "Write-test OK" }
catch { Log "WARN: write-test failed - $($_.Exception.Message)" }
Log "==== DONE ===="
Write-Host ""
Write-Host "C:\GE PC-DMIS FRONT END ready. Re-run the measurement; if PCDToIGES still" -ForegroundColor Green
Write-Host "errors, the real cause now logs to $dir\PCDToIGES.ERR" -ForegroundColor Green
exit 0

View File

@@ -11,8 +11,10 @@ resolve-cmm-bay-config.ps1 at the WinPE picker):
version it ever had (2016.0 + 2019 R2); we restore ONLY the
one the bay-config pins, matched by substring (2019 -> the
'2019 R2' zip, 2016 -> the '2016.0' zip).
doda.txt - 'no' to proceed; anything else SKIPS restore (DODA bays are
handled separately and must not get a settings overlay).
doda.txt - read for logging only. DODA bays now restore like any other:
once they are properly backed up + staged, the config-version
zip is restored the same way. (The old skip-on-doda policy was
dropped 2026-06-17; DODA bays had no backups when it was added.)
partgroup.txt - optional goCMM Selected Part Group (friendly S:\ form), used
as the authoritative per-bay override even if the backup is stale.
@@ -50,7 +52,9 @@ $doda = (ReadTxt 'doda.txt').ToLower()
$pgRaw = ReadTxt 'partgroup.txt'
if (-not $cmmid) { Log "no cmmid.txt (manual CMM id, or not a bay-config bay) - nothing to restore. Skipping."; exit 0 }
if ($doda -eq 'yes') { Log "DODA bay ($cmmid) - skipping settings restore by policy."; exit 0 }
# DODA bays restore like any other now (skip-on-doda dropped 2026-06-17). DODA
# itself installs separately via the manifest to C:\Apps\DODA - independent of
# the goCMM (C:\geaofi) + PC-DMIS settings this restore lays back.
$bdir = Join-Path $BackupRoot $cmmid
if (-not (Test-Path -LiteralPath $bdir)) { Log "no staged backup at $bdir - skipping (stage it with sync-cmm-backups.sh)."; exit 0 }

View File

@@ -4,9 +4,17 @@ REM
REM *** RUN THIS AS THE OPERATOR (the locked-down shop-floor user). ***
REM *** DO NOT right-click "Run as administrator" - that hides the bug. ***
REM
REM Reproduces the goCMM "Requested registry access is not allowed" error and
REM dumps the goCMM registry key's ACL so we can confirm the lockdown stripped
REM the BUILTIN\Users write grant. Output lands in C:\Logs\CMM\.
REM Diagnoses goCMM startup failures:
REM 1. "Requested registry access is not allowed" - dumps the goCMM key ACL
REM to confirm lockdown stripped the BUILTIN\Users write grant (PROBE 1-2).
REM 2. "Object reference not set to an instance of an object" at start:
REM - PROBE 3: part-group match (reg vs ApplicationSettings.xml, case-sensitive).
REM - PROBE 4: pulls the actual .NET crash stack from the Application log.
REM - PROBE 5: part-group UNC reachability + PC-DMIS COM registration.
REM Confirmed root cause to date: PC-DMIS COM server NOT registered ->
REM goCMM's Activator.CreateInstance gets a null type -> NRE. Fix: run
REM Pcdlrn.exe once elevated (/regserver). PROBE 5 reports this directly.
REM Output lands in C:\Logs\CMM\.
setlocal
set "HERE=%~dp0"

View File

@@ -93,14 +93,155 @@ W " exported -> $regOut"
W ""
W "================ goCMM version + install ================"
# The goCMM MSI installs goCMM.exe (1.1.6718.x) to this dir and points the Start
# Menu shortcut at it - goCMM.exe IS the launcher. (An earlier version of this
# script checked for GEAOperatorFriendlyInterface.exe, which this product does
# NOT install - that produced a false MISSING. Check the real exe.)
foreach ($p in @(
'C:\Program Files (x86)\General Electric\goCMM\GEAOperatorFriendlyInterface.exe',
'C:\Program Files (x86)\General Electric\goCMM\goCMM.exe',
'C:\Program Files (x86)\General Electric\goCMM\GEA_OFI_Common.dll')) {
if (Test-Path $p) { $vi = (Get-Item $p).VersionInfo; W (" {0} FileVer={1} ProductVer={2}" -f (Split-Path $p -Leaf), $vi.FileVersion, $vi.ProductVersion) }
else { W " MISSING: $p" }
}
W ""
W "================ PROBE 3: Selected Part Group vs ApplicationSettings.xml (the startup NRE) ================"
# goCMM at start matches the registry 'Selected Part Group' against the
# <PartGroup FullName> entries in ApplicationSettings.xml with a CASE-SENSITIVE
# (Ordinal) compare. No match -> SelectedPartGroup is null -> a startup deref
# throws "Object reference not set to an instance of an object". This is a
# DIFFERENT failure from the registry SecurityException probed above.
# Mismatch forms seen: host (bare vs FQDN) and share segment (\shared vs \SHARED).
try {
$base32 = [Microsoft.Win32.RegistryKey]::OpenBaseKey('LocalMachine','Registry32')
$kpg = $base32.OpenSubKey($key32native, $false)
$regPg = $null; $sharedDir = 'C:\geaofi'
if ($kpg) {
$regPg = [string]$kpg.GetValue('Selected Part Group','')
$sd = [string]$kpg.GetValue('Shared Data Directory','')
if ($sd) { $sharedDir = $sd.TrimEnd('\') }
$kpg.Close()
}
W (" reg 'Selected Part Group' = [{0}]" -f $regPg)
W (" Shared Data Directory = {0}" -f $sharedDir)
$xml = Join-Path $sharedDir 'ApplicationSettings.xml'
if (-not (Test-Path $xml)) {
W " *** ApplicationSettings.xml NOT FOUND at $xml - no part groups to match -> null SelectedPartGroup -> NRE likely. <<"
} else {
$txt = [System.IO.File]::ReadAllText($xml)
# Capture FullName in both attribute (FullName="...") and element (<FullName>...</FullName>) forms.
$names = New-Object System.Collections.Generic.List[string]
foreach ($m in [regex]::Matches($txt, 'FullName\s*=\s*"([^"]*)"')) { $names.Add($m.Groups[1].Value) }
foreach ($m in [regex]::Matches($txt, '<FullName>([^<]*)</FullName>')) { $names.Add($m.Groups[1].Value) }
$names = $names | Select-Object -Unique
W (" ApplicationSettings.xml part-group FullName entries ({0}):" -f @($names).Count)
foreach ($n in $names) { W (" [{0}]" -f $n) }
if (-not $regPg) {
W " >> reg 'Selected Part Group' is EMPTY - goCMM has no part group pinned -> null -> NRE likely. <<"
} else {
$exact = $false; $caseOnly = $false
foreach ($n in $names) {
if ([string]::Equals($n, $regPg, [StringComparison]::Ordinal)) { $exact = $true; break }
if ([string]::Equals($n, $regPg, [StringComparison]::OrdinalIgnoreCase)) { $caseOnly = $true }
}
if ($exact) {
W " >> MATCH: reg value matches an XML FullName exactly (case-sensitive). Part group is NOT the NRE cause. <<"
} elseif ($caseOnly) {
W " >> *** CASE-ONLY MATCH *** reg value matches an XML entry only when case is ignored."
W " >> goCMM's compare is case-sensitive -> Find returns null -> SelectedPartGroup null -> NRE. <<"
W " >> FIX: re-run Install-goCMMSettings.ps1 (>= commit d441abd canonicalizes host + \SHARED case). <<"
} else {
W " >> *** NO MATCH *** reg 'Selected Part Group' is absent from ApplicationSettings.xml (any case)."
W " >> Find returns null -> SelectedPartGroup null -> NRE. Wrong per-bay override, or XML from a different bay. <<"
}
}
}
} catch { W " ERROR in part-group probe: $($_.Exception.Message)" }
W ""
W "================ PROBE 4: goCMM crash events (Application log) - the actual stack ================"
# The NRE reproduces even for admin, so it is NOT a rights problem. The faulting
# stack names the null object. .NET pushes it to the Application log under
# '.NET Runtime' / 'Application Error' / 'Windows Error Reporting'.
try {
$since = (Get-Date).AddDays(-14)
$ev = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$since } -ErrorAction SilentlyContinue |
Where-Object { $_.ProviderName -match '\.NET Runtime|Application Error|Windows Error Reporting|Application Hang' -and "$($_.Message)" -match 'goCMM' }
if (-not $ev) {
W " (no goCMM-related crash events in the last 14 days - launch goCMM once, then re-run this)"
} else {
foreach ($e in ($ev | Select-Object -First 5)) {
W (" ---- {0} [{1}] EventId={2} ----" -f $e.TimeCreated, $e.ProviderName, $e.Id)
foreach ($line in ("$($e.Message)" -split "`r?`n")) { W (" " + $line) }
W ""
}
W " >> The topmost goCMM / GEA_OFI frame in the stack above is where the null is dereferenced. <<"
}
} catch { W " ERROR reading event log: $($_.Exception.Message)" }
W ""
W "================ PROBE 5: part-group UNC reachability + PC-DMIS install ================"
# (a) Can THIS bay open the Selected Part Group share? An unreachable / unauthenticated
# UNC -> goCMM enumerates null -> startup NRE, regardless of local rights (admin too).
try {
$base32 = [Microsoft.Win32.RegistryKey]::OpenBaseKey('LocalMachine','Registry32')
$kpg = $base32.OpenSubKey($key32native, $false)
$pg = if ($kpg) { [string]$kpg.GetValue('Selected Part Group','') } else { '' }
if ($kpg) { $kpg.Close() }
if ($pg) {
W (" part group UNC: $pg")
if (Test-Path -LiteralPath $pg -ErrorAction SilentlyContinue) {
$n = @(Get-ChildItem -LiteralPath $pg -ErrorAction SilentlyContinue).Count
W (" >> REACHABLE - $n item(s) under it. (not the NRE cause) <<")
} else {
W " >> *** UNREACHABLE *** bay cannot open the part-group UNC (share offline, not on the"
W " >> production net yet, or no credential to \\tsgwp00525\SHARED). goCMM enumerating this"
W " >> path returns null -> startup NRE even for admin. Check net connectivity + share auth. <<"
}
} else { W " (no Selected Part Group in registry)" }
} catch { W " ERROR testing part-group UNC: $($_.Exception.Message)" }
W ""
# (b) PC-DMIS COM automation - THE confirmed root cause (see debug.log). goCMM
# connects via Activator.CreateInstance on the PC-DMIS COM type. If the
# automation server is not registered, Type.GetTypeFromProgID returns null
# -> CreateInstance(null) -> "ArgumentNullException: ... Parameter name: type"
# -> ConnectToSoftware derefs null -> NRE. PC-DMIS can be RUNNING and still
# not be COM-registered. Stack: GEAE.Common.CMM.CMMInterfaces.PCDMIS.ConnectToSoftware.
$comOk = $false
foreach ($pgid in 'PCDLRN.Application','PCDLRN.Automation','Pcdlrn.Application','PCDLRN.Object') {
foreach ($cls in "HKLM:\SOFTWARE\Classes\$pgid","HKLM:\SOFTWARE\Classes\WOW6432Node\$pgid") {
if (Test-Path $cls) {
$clsid = (Get-ItemProperty -Path "$cls\CLSID" -ErrorAction SilentlyContinue).'(default)'
$comOk = $true
$srv = $null
if ($clsid) {
foreach ($cb in "HKLM:\SOFTWARE\Classes\CLSID\$clsid\LocalServer32","HKLM:\SOFTWARE\Classes\WOW6432Node\CLSID\$clsid\LocalServer32") {
if (Test-Path $cb) { $srv = (Get-ItemProperty -Path $cb -EA SilentlyContinue).'(default)' }
}
}
W (" COM ProgID registered: $pgid CLSID=$clsid server=$srv")
}
}
}
if (-not $comOk) {
W " >> *** PC-DMIS COM SERVER NOT REGISTERED *** no PCDLRN.* ProgID in HKCR. This is exactly the"
W " >> null behind 'ArgumentNullException: ... Parameter name: type' in debug.log -> goCMM startup NRE."
W " >> FIX: run the installed Pcdlrn.exe once ELEVATED (self-registers), or 'Pcdlrn.exe /regserver'."
W " >> Then confirm the registered build matches the goCMM-expected PC-DMIS version (the version gate). <<"
}
# install presence (context for the COM verdict)
$pcdlrn = $null
foreach ($d in 'C:\Program Files\Hexagon','C:\Program Files (x86)\Hexagon','C:\Program Files\WAI','C:\Program Files (x86)\WAI') {
if (Test-Path $d) {
$hit = Get-ChildItem $d -Filter 'Pcdlrn.exe' -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1
if ($hit) { $pcdlrn = $hit.FullName; W (" Pcdlrn.exe: $($hit.FullName) v$((Get-Item $hit.FullName).VersionInfo.FileVersion)"); break }
}
}
if (-not $pcdlrn) { W " Pcdlrn.exe NOT found in standard dirs - PC-DMIS not installed where expected." }
W ""
W "================ UAC / registry virtualization ================"
(reg query "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" /v EnableLUA 2>&1 | Out-String) | W
(reg query "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" /v EnableVirtualization 2>&1 | Out-String) | W

View File

@@ -0,0 +1,35 @@
@echo off
REM ==========================================================================
REM Diagnose-eDNCDetection.bat - diagnose the eDNC 6.4.5 enforce loop (MSI
REM 1603 "same or newer already installed" every 5 minutes).
REM
REM Usage (run on the problem PC):
REM Diagnose-eDNCDetection.bat auto-find manifest (W: or UNC)
REM Diagnose-eDNCDetection.bat /manifest <path> explicit manifest.json path
REM
REM Read-only: no installs, no share writes.
REM ==========================================================================
setlocal EnableDelayedExpansion
REM --- self-elevate ---
net session >nul 2>&1
if %errorlevel% neq 0 (
echo Requesting administrator elevation...
powershell -NoProfile -Command "Start-Process -Verb RunAs -FilePath '%~f0' -ArgumentList '%*'"
exit /b
)
set "PS=%~dp0Diagnose-eDNCDetection.ps1"
set "ARGS="
:parse
if "%~1"=="" goto run
if /I "%~1"=="/manifest" set "ARGS=!ARGS! -ManifestPath '%~2'" & shift & shift & goto parse
shift
goto parse
:run
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%PS%" !ARGS!
echo.
pause
endlocal

View File

@@ -0,0 +1,232 @@
<#
Diagnose-eDNCDetection.ps1
One-shot diagnosis of the eDNC 6.4.5 enforce loop: GE-Enforce re-runs
eDNC_6-4-5.msi every 5 minutes, the MSI aborts 1603 with "The same or newer
version of this software is already installed", and InUseCheck kills DncMain
in the process. Root cause is the manifest entry's Detection* fields not
matching what is actually installed.
Runs the whole triage and prints a verdict:
1. Installed state - DncMain.exe / NTLARS.exe file versions, x86+x64
uninstall registry entries for eDNC (DisplayVersion,
ProductCode).
2. Manifest entry - the live "eDNC (bundles NTLARS)" entry from the SFLD
share manifest (uses W: if enforce has it mounted,
else direct UNC, else -ManifestPath).
3. Comparison - replays Install-FromManifest's Test-AppInstalled
logic against the entry and says WHY it misses.
4. Verdict - suggested corrected manifest JSON.
Read-only: no installs, no share writes. Run as administrator on the
problem PC (registry + share read).
Params:
-ManifestPath explicit path to the collections manifest.json (skips
W:/UNC auto-discovery)
#>
param(
[string]$ManifestPath
)
$ErrorActionPreference = 'Continue'
function Section($t){ Write-Host ''; Write-Host ("==== {0} ====" -f $t) -ForegroundColor Cyan }
function KV($k,$v){ Write-Host (" {0,-24}: {1}" -f $k, $v) }
Write-Host '########################################################'
Write-Host '# eDNC Detection Loop Diagnosis'
Write-Host ("# {0} host {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), [System.Environment]::MachineName)
Write-Host '########################################################'
# ---------------------------------------------------------------------------
# 1. Installed state
# ---------------------------------------------------------------------------
Section '1. Installed eDNC state on this PC'
$dncMain = 'C:\Program Files (x86)\Dnc\bin\DncMain.exe'
$ntlars = 'C:\Program Files (x86)\Dnc\Common\NTLARS.exe'
$dncMainVer = ''
$ntlarsVer = ''
if (Test-Path $dncMain) {
$dncMainVer = (Get-Item $dncMain).VersionInfo.FileVersion
KV 'DncMain.exe' ("present, FileVersion {0}" -f $dncMainVer)
} else {
KV 'DncMain.exe' 'MISSING'
}
if (Test-Path $ntlars) {
$ntlarsVer = (Get-Item $ntlars).VersionInfo.FileVersion
KV 'NTLARS.exe' ("present, FileVersion {0}" -f $ntlarsVer)
} else {
KV 'NTLARS.exe' 'MISSING'
}
# Uninstall entries - both registry views. Win32_Product deliberately avoided
# (it triggers MSI self-repair on enumeration).
$uninstallRoots = @(
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'
)
$edncProducts = @()
foreach ($root in $uninstallRoots) {
$edncProducts += Get-ItemProperty -Path $root -ErrorAction SilentlyContinue |
Where-Object { $_.DisplayName -like '*eDNC*' -or $_.DisplayName -like '*DNC*Client*' }
}
if ($edncProducts) {
foreach ($p in $edncProducts) {
Write-Host ''
KV 'DisplayName' $p.DisplayName
KV 'DisplayVersion' $p.DisplayVersion
KV 'ProductCode' $p.PSChildName
KV 'InstallDate' $p.InstallDate
KV 'RegistryView' ($(if ($p.PSPath -like '*WOW6432Node*') { 'x86 (WOW6432Node)' } else { 'x64' }))
}
} else {
Write-Host ' No eDNC uninstall entry found in either registry view.' -ForegroundColor Yellow
}
# ---------------------------------------------------------------------------
# 2. Manifest entry from the share
# ---------------------------------------------------------------------------
Section '2. Manifest entry (share)'
$candidates = @()
if ($ManifestPath) { $candidates += $ManifestPath }
$candidates += 'W:\gea-shopfloor-collections\manifest.json'
$candidates += '\\tsgwp00525.wjs.geaerospace.net\shared\dt\shopfloor\gea-shopfloor-collections\manifest.json'
$manifestFile = $null
foreach ($c in $candidates) {
if (Test-Path $c) { $manifestFile = $c; break }
}
$entry = $null
if ($manifestFile) {
KV 'Manifest' $manifestFile
try {
$manifest = Get-Content -LiteralPath $manifestFile -Raw | ConvertFrom-Json
$entry = $manifest.Applications | Where-Object { $_.Name -like 'eDNC*' } | Select-Object -First 1
} catch {
Write-Host (" Failed to parse manifest: {0}" -f $_) -ForegroundColor Red
}
if ($entry) {
KV 'Name' $entry.Name
KV 'Installer' $entry.Installer
KV 'Type' $entry.Type
KV 'InstallArgs' $entry.InstallArgs
KV 'DetectionMethod' $entry.DetectionMethod
KV 'DetectionPath' $entry.DetectionPath
KV 'DetectionName' $entry.DetectionName
KV 'DetectionValue' $entry.DetectionValue
} else {
Write-Host ' No eDNC* entry found in manifest Applications.' -ForegroundColor Yellow
}
} else {
Write-Host ' Manifest not reachable (no W: mount, UNC denied).' -ForegroundColor Yellow
Write-Host ' Re-run with -ManifestPath, or run while GE-Enforce has W: mounted.'
}
# ---------------------------------------------------------------------------
# 3. Replay Test-AppInstalled against the entry
# ---------------------------------------------------------------------------
Section '3. Detection replay'
$detected = $null
$missReason = ''
if ($entry) {
switch ($entry.DetectionMethod) {
'File' {
$detected = Test-Path $entry.DetectionPath
if (-not $detected) { $missReason = "DetectionPath does not exist: $($entry.DetectionPath)" }
}
'FileVersion' {
if (-not (Test-Path $entry.DetectionPath)) {
$detected = $false
$missReason = "DetectionPath does not exist: $($entry.DetectionPath)"
} elseif (-not $entry.DetectionValue) {
$detected = $false
$missReason = 'FileVersion detection with no DetectionValue always misses'
} else {
$actual = (Get-Item $entry.DetectionPath -ErrorAction SilentlyContinue).VersionInfo.FileVersion
$detected = ($actual -eq $entry.DetectionValue)
if (-not $detected) {
$missReason = "exact-match compare failed: actual '$actual' vs DetectionValue '$($entry.DetectionValue)'"
}
}
}
'Registry' {
if (-not (Test-Path $entry.DetectionPath)) {
$detected = $false
$missReason = "registry key does not exist: $($entry.DetectionPath) (eDNC uninstall key path varies across 6.x releases)"
} elseif ($entry.DetectionName) {
$v = Get-ItemProperty -Path $entry.DetectionPath -Name $entry.DetectionName -ErrorAction SilentlyContinue
if (-not $v) {
$detected = $false
$missReason = "value '$($entry.DetectionName)' missing under $($entry.DetectionPath)"
} elseif ($entry.DetectionValue) {
$actual = $v.$($entry.DetectionName)
$detected = ("$actual" -eq "$($entry.DetectionValue)")
if (-not $detected) {
$missReason = "exact-match compare failed: actual '$actual' vs DetectionValue '$($entry.DetectionValue)'"
}
} else {
$detected = $true
}
} else {
$detected = $true
}
}
default {
$missReason = "detection method '$($entry.DetectionMethod)' not replayed by this script"
}
}
if ($null -ne $detected) {
KV 'Test-AppInstalled' ($(if ($detected) { 'TRUE (would skip install)' } else { 'FALSE (re-runs MSI every cycle)' }))
if ($missReason) { KV 'Miss reason' $missReason }
}
} else {
Write-Host ' Skipped - no manifest entry available to replay.'
}
# ---------------------------------------------------------------------------
# 4. Verdict
# ---------------------------------------------------------------------------
Section '4. Verdict'
$edncInstalled = ($edncProducts.Count -gt 0) -or (Test-Path $dncMain)
if (-not $edncInstalled) {
Write-Host ' eDNC does NOT appear installed. The 1603 "same or newer" abort'
Write-Host ' then points at a stale MSI registration; check the upgrade-code'
Write-Host ' products under HKLM:\SOFTWARE\Classes\Installer\UpgradeCodes.'
} elseif ($detected -eq $true) {
Write-Host ' Detection passes now. If enforce still loops, the manifest on the'
Write-Host ' share differs from the one inspected here - confirm the path.'
} else {
Write-Host ' eDNC IS installed but the manifest entry does not detect it, so'
Write-Host ' GE-Enforce re-runs the MSI every cycle and the MSI aborts 1603'
Write-Host ' ("same or newer already installed"). Fix the manifest entry on'
Write-Host ' the share. Suggested replacement (File detection on NTLARS.exe,'
Write-Host ' same approach as the working 6.4.3 machineapps entry):'
Write-Host ''
$suggested = [ordered]@{
Name = 'eDNC (bundles NTLARS)'
Installer = 'apps\eDNC_6-4-5.msi'
Type = 'MSI'
InstallArgs = '/qn /norestart ALLUSERS=1 REBOOT=ReallySuppress SITESELECTED="West Jefferson"'
DetectionMethod = 'File'
DetectionPath = 'C:\Program Files (x86)\Dnc\Common\NTLARS.exe'
} | ConvertTo-Json
Write-Host $suggested
Write-Host ''
if ($edncProducts) {
$dv = ($edncProducts | Select-Object -First 1).DisplayVersion
Write-Host (" Alternative: keep Registry detection but set DetectionValue to the")
Write-Host (" actual installed DisplayVersion: '{0}'" -f $dv)
}
Write-Host ' Until the manifest is fixed, every enforce cycle also risks killing'
Write-Host ' a live DncMain session (InUseCheck force-kill before the doomed MSI).'
}
Write-Host ''

View File

@@ -1,21 +1,28 @@
# Report-AssetToShopDB.ps1
#
# Reports a collections bay's identity to ShopDB so the machines record stays
# current with whatever the bay actually is right now: hostname, BIOS serial,
# DNC machine number (2001, 2002, ...) and its corp/AESFMA IPv4 address.
# Reports a PC's identity to ShopDB so the machines record stays current with
# whatever the PC actually is right now: hostname, BIOS serial, pc-type, logged-
# in user, DNC machine number (2001, 2002, ... when present) and its corp/AESFMA
# IPv4 address.
#
# Deployed in common\ (runs on EVERY shopfloor pc-type: collections, cmm,
# keyence, waxtrace, genspect, heattreat, partmarker, nocollections, ...), not
# collections-only. Non-DNC bays (cmm/keyence/waxtrace) simply report identity
# with no machineNo, so no PC-to-machine relationship is built - by design.
#
# Runs every GE-Enforce cycle as a Type=PS1 manifest entry (DetectionMethod
# Always) under the SYSTEM scheduled task. Idempotent on the server side:
# ShopDB api.asp action=updateCompleteAsset upserts the machines row keyed by
# hostname, clears+reinserts the interface rows, and (re)creates the
# PC-to-machine relationship from machineNo. Safe to fire repeatedly.
# hostname (patch-style: only the fields posted here are updated, so it never
# clobbers model/VNC/WinRM), clears+reinserts the interface rows, and (re)creates
# the PC-to-machine relationship from machineNo. Safe to fire repeatedly.
#
# WHY collections-only and corp-NIC-only:
# Collections (controller-NIC) bays carry two NICs - a private controller
# NIC (e.g. 192.168.x / 10.x stray) and the routable corp/AESFMA NIC. Only
# the corp NIC belongs in ShopDB, so we filter to the WJ corp ranges and
# drop everything else. Mirrors the allowed-range gate in
# Invoke-FilteredReportIP.ps1.
# WHY corp-NIC-only:
# Some bays (collections/controller) carry two NICs - a private controller NIC
# (e.g. 192.168.x / 10.x stray) and the routable corp/AESFMA NIC. Only the corp
# NIC belongs in ShopDB, so we filter to the WJ corp ranges and drop the rest.
# Single-NIC PCs just pass their one corp IP through the same gate. Mirrors the
# allowed-range gate in Invoke-FilteredReportIP.ps1.
#
# Always exits 0 so the GE-Enforce "last run result" stays clean; failures are
# logged, never thrown.
@@ -83,15 +90,17 @@ if (-not $serialNumber) {
exit 0
}
# DNC machine number (2001, 2002, ...). optional - sent only if found.
# Resolution order mirrors the GE-Enforce lib Get-CurrentMachineNumber so the
# reporter and manifest gating agree:
# 1. eDNC registry (WOW6432Node, then native) - follows bay reassignment, which
# Set-MachineNumber rewrites here.
# 2. C:\Enrollment\machine-number.txt - the imaging-time value written once by
# startnet.cmd. Used when eDNC has not populated the registry yet (fresh
# image, or a bay where eDNC has not run), so the PC still reports its number
# and api.asp can build the relationship.
# Machine identifier - optional, sent only if found. api.asp matches it against
# the equipment machinenumber OR alias column, so one value covers every type:
# 1. eDNC registry (WOW6432Node, then native) - DNC/collections bays (2001...).
# Follows bay reassignment, which Set-MachineNumber rewrites here.
# 2. C:\Enrollment\cmm\cmmid.txt - CMM bay id (e.g. CMM3), written by
# select-cmm-bay.ps1 at imaging. Matches the equipment machinenumber.
# 3. C:\Enrollment\machine-number.txt - imaging value. For wax&trace this is the
# asset tag from select-waxtrace-asset.ps1 (matches the equipment alias);
# for DNC bays it is the digit number. 9999 is the placeholder = skip.
# keyence / genspect / part-marker have no per-bay id on the PC -> no machineNo
# is sent and the link is assigned manually in ShopDB.
$machineNo = ''
foreach ($regPath in @(
'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General',
@@ -101,43 +110,115 @@ foreach ($regPath in @(
try {
if (Test-Path $regPath) {
$v = [string](Get-ItemProperty -Path $regPath -Name MachineNo -ErrorAction Stop).MachineNo
if ($v) { $machineNo = $v.Trim() }
if ($v -and $v.Trim() -ne '9999') { $machineNo = $v.Trim() }
}
} catch {
Log "WARN could not read MachineNo from ${regPath}: $($_.Exception.Message)"
}
}
if (-not $machineNo) {
$cmmFile = 'C:\Enrollment\cmm\cmmid.txt'
if (Test-Path -LiteralPath $cmmFile) {
try {
$v = ([string](Get-Content -LiteralPath $cmmFile -First 1 -ErrorAction Stop)).Trim()
if ($v -and $v -ne '9999') { $machineNo = $v; Log "machineNo from $cmmFile (CMM bay id): $machineNo" }
} catch { Log "WARN could not read ${cmmFile}: $($_.Exception.Message)" }
}
}
if (-not $machineNo) {
$mnFile = 'C:\Enrollment\machine-number.txt'
if (Test-Path -LiteralPath $mnFile) {
try {
$v = Get-Content -LiteralPath $mnFile -First 1 -ErrorAction Stop
if ($v) { $machineNo = ([string]$v).Trim(); Log "machineNo from $mnFile (eDNC registry empty): $machineNo" }
$v = ([string](Get-Content -LiteralPath $mnFile -First 1 -ErrorAction Stop)).Trim()
if ($v -and $v -ne '9999') { $machineNo = $v; Log "machineNo from $mnFile (imaging value): $machineNo" }
} catch { Log "WARN could not read ${mnFile}: $($_.Exception.Message)" }
}
}
# OS caption for the operatingsystems lookup.
# OS for the operatingsystems lookup: caption + feature-update (e.g. 23H2, read
# from the registry since WMI does not expose it) + major build number. Yields a
# string like "Microsoft Windows 11 Enterprise 23H2 (build 22631)" so the fleet
# OS-version breakdown is visible everywhere osid is shown - no schema change,
# api.asp upserts each distinct string into operatingsystems.
$osVersion = ''
# last boot time - api.asp stores it as machines.lastboottime; uptime is derived
# server-side (DATEDIFF(NOW(), lastboottime)). MySQL datetime format.
$lastBootTime = ''
try {
$osVersion = (Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop).Caption
if ($osVersion) { $osVersion = $osVersion.Trim() }
$os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop
$osVersion = "$($os.Caption)".Trim()
$displayVersion = ''
try {
$displayVersion = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -Name DisplayVersion -ErrorAction Stop).DisplayVersion
} catch {}
if ($displayVersion) { $osVersion += " $displayVersion" }
if ($os.BuildNumber) { $osVersion += " (build $($os.BuildNumber))" }
$osVersion = $osVersion.Trim()
try { $lastBootTime = $os.LastBootUpTime.ToString('yyyy-MM-dd HH:mm:ss') } catch {}
} catch {}
# gather corp NICs only. one networkInterfaces entry per allowed IPv4.
# interactive console user (DOMAIN\user). this script runs as SYSTEM so we
# cannot use $env:USERNAME; Win32_ComputerSystem.UserName is the console
# session owner and works from SYSTEM context. empty when nobody is logged on,
# in which case we omit it from the post so an unattended bay does not blank
# the last-known user on the server.
$loggedInUser = ''
try {
$loggedInUser = (Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop).UserName
# Win32 returns COMPUTERNAME\user (local account) or DOMAIN\user. Keep only
# the username part: matches the legacy bare-username convention and avoids
# the backslash, which the inline api.asp SQL does not escape so MySQL eats
# it (FB90238\ShopFloor was being stored as FB90238ShopFloor).
if ($loggedInUser) { $loggedInUser = ($loggedInUser -split '\\')[-1].Trim() }
} catch {
Log "WARN could not read logged-in user: $($_.Exception.Message)"
}
# imaging pc-type (gea-shopfloor-*), read from the enrollment file written at
# image time. api.asp maps both the gea-shopfloor-* values and the legacy
# display strings to the right pctypeid. Sent only when present; when absent the
# server's patch-style update leaves the existing pctype untouched (so a bare
# report never re-types a PC). This is what lets the reporter run fleet-wide
# from common\ instead of collections-only.
$pcType = ''
$ptFile = 'C:\Enrollment\pc-type.txt'
if (Test-Path -LiteralPath $ptFile) {
try {
$pcType = (Get-Content -LiteralPath $ptFile -First 1 -ErrorAction Stop).Trim()
} catch { Log "WARN could not read ${ptFile}: $($_.Exception.Message)" }
}
# PC make/model from WMI (e.g. "Dell Inc." / "OptiPlex 7090"). api.asp resolves
# or creates the vendor + model rows and links modelnumberid. Sent only when
# present so a WMI read failure does not blank the model on the row.
$manufacturer = ''
$model = ''
try {
$cs = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop
$manufacturer = "$($cs.Manufacturer)".Trim()
$model = "$($cs.Model)".Trim()
} catch {
Log "WARN could not read make/model: $($_.Exception.Message)"
}
# gather IPv4 NICs - BOTH the corp/AESFMA NIC and the controller/machine LAN NIC,
# each with its MAC. Physical adapters only (drop Hyper-V/VPN/WSL/virtual plus
# link-local 169.254 and loopback). Each NIC is tagged IsMachineNetwork: true for
# the controller LAN (any IP outside the corp ranges), false for the corp NIC.
$interfaces = @()
try {
$ipObjs = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop |
Where-Object { $_.IPAddress -notmatch '^169\.254' -and $_.IPAddress -ne '127.0.0.1' }
foreach ($ipo in $ipObjs) {
if (-not (Test-InAllowedRange $ipo.IPAddress)) { continue }
$adapter = $null
try { $adapter = Get-NetAdapter -InterfaceIndex $ipo.InterfaceIndex -ErrorAction Stop } catch {}
# physical + connected only; skip virtual adapters (Hyper-V/VPN/WSL/etc)
if (-not $adapter) { continue }
if (-not $adapter.HardwareInterface) { continue }
if ($adapter.Status -ne 'Up') { continue }
$mac = ''
$mac = $adapter.MacAddress
$gw = ''
try {
$adapter = Get-NetAdapter -InterfaceIndex $ipo.InterfaceIndex -ErrorAction Stop
$mac = $adapter.MacAddress
} catch {}
try {
$gw = (Get-NetRoute -InterfaceIndex $ipo.InterfaceIndex -DestinationPrefix '0.0.0.0/0' -ErrorAction Stop |
Select-Object -First 1).NextHop
@@ -149,13 +230,15 @@ try {
[Array]::Reverse($maskBytes)
$subnetMask = ($maskBytes | ForEach-Object { $_ }) -join '.'
$isCorp = Test-InAllowedRange $ipo.IPAddress
$interfaces += [pscustomobject]@{
IPAddress = $ipo.IPAddress
MACAddress = $mac
SubnetMask = $subnetMask
DefaultGateway = $gw
InterfaceName = $ipo.InterfaceAlias
IsMachineNetwork = $false # corp NIC, not the controller LAN
IsMachineNetwork = (-not $isCorp) # controller/machine LAN = true; corp = false
}
}
} catch {
@@ -163,7 +246,7 @@ try {
}
if ($interfaces.Count -eq 0) {
Log 'WARN no corp-range IPv4 found; posting identity without interfaces.'
Log 'WARN no physical IPv4 NIC found; posting identity without interfaces.'
}
$networkInterfacesJson = if ($interfaces.Count -gt 0) { $interfaces | ConvertTo-Json -Compress -Depth 4 } else { '' }
@@ -174,14 +257,18 @@ $body = @{
action = 'updateCompleteAsset'
hostname = $hostname
serialNumber = $serialNumber
pcType = 'Shopfloor'
osVersion = $osVersion
networkInterfaces = $networkInterfacesJson
}
if ($machineNo) { $body['machineNo'] = $machineNo }
if ($machineNo) { $body['machineNo'] = $machineNo }
if ($loggedInUser) { $body['loggedInUser'] = $loggedInUser }
if ($pcType) { $body['pcType'] = $pcType }
if ($manufacturer) { $body['manufacturer'] = $manufacturer }
if ($model) { $body['model'] = $model }
if ($lastBootTime) { $body['lastBootTime'] = $lastBootTime }
Log ("POST {0} host={1} serial={2} machineNo={3} ips={4}" -f `
$ApiUrl, $hostname, $serialNumber, $machineNo, (($interfaces | ForEach-Object { $_.IPAddress }) -join ','))
Log ("POST {0} host={1} serial={2} pcType={3} make={4} model={5} os={6} boot={7} machineNo={8} user={9} ips={10}" -f `
$ApiUrl, $hostname, $serialNumber, $pcType, $manufacturer, $model, $osVersion, $lastBootTime, $machineNo, $loggedInUser, (($interfaces | ForEach-Object { $_.IPAddress }) -join ','))
try {
$resp = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $body -TimeoutSec $TimeoutSec -ErrorAction Stop

View File

@@ -147,7 +147,7 @@ if (-not $mn -or $mn -eq '9999' -or $mn -notmatch '^\d+$') {
function Mount-SFLDShare {
param(
[Parameter(Mandatory)][string]$SharePath,
[string]$DriveLetter = 'V:'
[string]$DriveLetter = 'R:'
)
$server = ($SharePath -replace '^\\\\', '') -split '\\' | Select-Object -First 1
$basePath = 'HKLM:\SOFTWARE\GE\SFLD\Credentials'
@@ -178,11 +178,18 @@ function Mount-SFLDShare {
return ($LASTEXITCODE -eq 0)
}
Log "Mounting share with SFLD creds: $BackupShareRoot -> W:"
# R:, not W: and not V:. GE-Enforce owns W: for the whole enforce cycle
# (GE-Enforce.ps1 $driveLetter). This script runs as a manifest PS1 entry
# inside that cycle, so remapping/deleting W: here kills the share for every
# later manifest entry - Join-Path on a dead W: returns null and the next
# entry dies with "Cannot bind argument to parameter 'LiteralPath' because it
# is null". V: is no good either: Update-MachineNumber.ps1 / Restore-EDncReg.ps1
# mount V: and run as SYSTEM in the same drive namespace. R: is unclaimed.
Log "Mounting share with SFLD creds: $BackupShareRoot -> R:"
$shareMounted = $false
$sw = [Diagnostics.Stopwatch]::StartNew()
while ($sw.Elapsed.TotalSeconds -lt $ShareTimeoutSec) {
if (Mount-SFLDShare -SharePath $BackupShareRoot -DriveLetter 'W:') {
if (Mount-SFLDShare -SharePath $BackupShareRoot -DriveLetter 'R:') {
$shareMounted = $true
break
}
@@ -190,16 +197,16 @@ while ($sw.Elapsed.TotalSeconds -lt $ShareTimeoutSec) {
}
$sw.Stop()
if ($shareMounted) {
Log ("Share mounted as W: after {0:N1} s" -f $sw.Elapsed.TotalSeconds)
Log ("Share mounted as R: after {0:N1} s" -f $sw.Elapsed.TotalSeconds)
} else {
Log "Mount-SFLDShare failed after $ShareTimeoutSec s. SFLD creds may be missing in HKLM:\SOFTWARE\GE\SFLD\Credentials, or the share is unreachable. Exiting non-zero so the dispatcher logs a failure." 'ERROR'
Log 'Exit 1.'
exit 1
}
# All bay-level paths now go through W: (authenticated as SFLD user) so
# All bay-level paths now go through R: (authenticated as SFLD user) so
# Test-Path returns the truth, not access-denied-False.
$bayDir = Join-Path 'W:\' $mn
$bayDir = Join-Path 'R:\' $mn
$srcCur = Join-Path $bayDir 'CurrentData.json'
$srcArc = Join-Path $bayDir 'ArchivedData'
Log "Probing backup paths for bay $mn"
@@ -213,7 +220,7 @@ Log " ArchivedData/ src: $(if ($srcArcExists) { 'present' } else { 'absent'
if (-not $srcCurExists -and -not $srcArcExists) {
Log "No backup waiting for bay $mn (neither CurrentData.json nor ArchivedData\ at bay root) - no work to do this cycle."
& net use W: /delete /y 2>$null | Out-Null
& net use R: /delete /y 2>$null | Out-Null
Log 'Exit 0.'
exit 0
}
@@ -251,7 +258,7 @@ if (-not (Test-Path -LiteralPath $UdcDataDir)) {
} catch {
Log "Failed to create $UdcDataDir - cannot continue" 'ERROR'
LogErr $_
& net use W: /delete /y 2>$null | Out-Null
& net use R: /delete /y 2>$null | Out-Null
Log 'Exit 1.'
exit 1
}
@@ -376,7 +383,7 @@ if ((Test-Path -LiteralPath $UdcExePath) -and ($copiedCur -or $copiedArc)) {
}
# Unmount the SFLD-creds-mounted drive so we don't leave a stale net-use entry
& net use W: /delete /y 2>$null | Out-Null
& net use R: /delete /y 2>$null | Out-Null
Log 'Exit 0.'
Log '==============================================='

View File

@@ -52,6 +52,40 @@ Write-Host " Edge RelaunchNotificationPeriod=1h"
Write-Host " Edge RelaunchHeadsUpPeriod=1m"
Write-Host " Edge RelaunchWindow=02:00-04:00"
# --- Arm the ShopDB kiosk bootstrap -------------------------------------
# A PXE-imaged display ends up with no GE-Enforce client: the installer pulls
# itself from {BaseUrl}/installers/kiosk over HTTPS, and ShopDB is unreachable
# until the bay joins the AESFMA wifi SSID. So it cannot run now.
#
# Register a SYSTEM task that does nothing until ShopDB answers, then runs the
# vendor installer once and unregisters itself. At boot and every 15 minutes,
# so a bay cabled up days later still self-configures.
Write-Host '=== Arming ShopDB kiosk bootstrap ==='
$bootstrap = Join-Path $PSScriptRoot 'Install-ShopdbKiosk-WhenOnline.ps1'
$taskName = 'ShopDB Kiosk Bootstrap'
if (-not (Test-Path $bootstrap)) {
Write-Warning " $bootstrap not found - kiosk will NOT self-configure. Tell the PXE admin."
} else {
try {
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument ('-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "{0}" -TaskName "{1}"' -f $bootstrap, $taskName)
$trigBoot = New-ScheduledTaskTrigger -AtStartup
$trigRep = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(5) `
-RepetitionInterval (New-TimeSpan -Minutes 15)
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
-StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 30)
Register-ScheduledTask -TaskName $taskName -Action $action `
-Trigger @($trigBoot, $trigRep) -Principal $principal -Settings $settings `
-Force -ErrorAction Stop | Out-Null
Write-Host " Registered '$taskName' (at boot + every 15 min, SYSTEM)"
Write-Host " It waits for ShopDB, installs the enforce client, then removes itself."
} catch {
Write-Warning " Failed to register '$taskName': $_"
}
}
if (Get-Command Send-PxeStatus -ErrorAction SilentlyContinue) {
Send-PxeStatus -Stage '09-Setup-Display: complete' -StageIndex 4 -StageTotal 8
}

View File

@@ -0,0 +1,189 @@
<#
.SYNOPSIS
Run the ShopDB kiosk installer as soon as ShopDB becomes reachable.
.DESCRIPTION
A PXE-imaged display currently ends up with NO GE-Enforce client at all.
Confirmed on 579C144, 2026-08-06:
is the client installed? NOT FOUND
scheduled tasks that would run it? NONE
The client is delivered by Install-ShopdbKiosk.ps1, which downloads itself
from {BaseUrl}/installers/kiosk over HTTPS - and ShopDB is only reachable
once the bay has joined the AESFMA wifi SSID. So the installer cannot run
during imaging, and nothing was arranged to run it afterwards.
This closes that gap. Imaging stages the installer and the key; this waiter
runs as SYSTEM on a schedule, does nothing until ShopDB answers, then runs
the installer once and removes itself.
Deliberately does NOT reimplement the installer. It waits, invokes, verifies
and cleans up - the vendor script keeps doing the actual work, so it can be
replaced wholesale without touching this.
.PARAMETER EnrollmentRoot
Where imaging staged things. Expects:
<root>\display-type.txt Dashboard | Lobby | 3DPrintRoom
and, beside this script (see -KioskRoot):
kiosk\Install-ShopdbKiosk.ps1
kiosk\shopdb-key.txt (optional) labelled: collector=..., fetch=...
.PARAMETER TaskName
The scheduled task to remove once installation succeeds.
.NOTES
The key file is deleted after a successful install. The installer writes the
secrets into HKLM:\SOFTWARE\GE\ShopDB, which is ACLed to SYSTEM and
Administrators - a better home than a file readable by any local user until
lockdown. Bounding that exposure to the imaging window is the point.
#>
[CmdletBinding()]
param(
[string]$EnrollmentRoot = 'C:\Enrollment',
# Where the staged installer and key live. Defaults to kiosk\ NEXT TO THIS
# SCRIPT, not under EnrollmentRoot: startnet copies the whole type-specific
# tree to
# C:\Enrollment\shopfloor-setup\gea-shopfloor-display\
# so the payload arrives beside this file, not at C:\Enrollment\kiosk.
# Getting this wrong is silent - the bootstrap just logs "not staged" every
# cycle and never installs anything.
[string]$KioskRoot = (Join-Path $PSScriptRoot 'kiosk'),
[string]$BaseUrl = 'https://tsgwp00525.wjs.geaerospace.net/shopdb',
[string]$TaskName = 'ShopDB Kiosk Bootstrap',
[int]$TimeoutSeconds = 30
)
$ErrorActionPreference = 'Continue'
$logDir = 'C:\Logs\ShopDB'
$log = Join-Path $logDir 'kiosk-bootstrap.log'
New-Item -ItemType Directory -Path $logDir -Force -EA SilentlyContinue | Out-Null
function Log { param([string]$m)
$line = "{0} {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $m
Write-Host $line
Add-Content -Path $log -Value $line -EA SilentlyContinue
}
function Remove-Self {
try {
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -EA Stop
Log "Unregistered '$TaskName' - nothing further to do."
} catch { Log "Could not unregister '$TaskName': $_" }
}
Log "=== kiosk bootstrap ==="
# --- already done? ------------------------------------------------------
# Both conditions, not just the registry key: BaseUrl alone can be present from
# a partial run, and the enforce task is what actually proves an install.
$reg = 'HKLM:\SOFTWARE\GE\ShopDB'
$haveCfg = (Test-Path $reg) -and ((Get-ItemProperty $reg -EA SilentlyContinue).BaseUrl)
$haveTask = [bool](Get-ScheduledTask -EA SilentlyContinue |
Where-Object { $_.TaskName -match '(?i)shopdb.*enforce' })
if ($haveCfg -and $haveTask) {
Log "Already installed (BaseUrl set, enforce task present)."
Remove-Self
return
}
# --- what kind of display is this? --------------------------------------
$dtFile = Join-Path $EnrollmentRoot 'display-type.txt'
if (-not (Test-Path $dtFile)) {
Log "No $dtFile - cannot choose a DisplayType. Leaving the task armed."
return
}
$displayType = (Get-Content $dtFile -First 1).Trim()
if ($displayType -notin @('Dashboard','Lobby','3DPrintRoom')) {
Log "display-type.txt says '$displayType', which the installer will reject. Leaving armed."
return
}
Log "DisplayType: $displayType"
$installer = Join-Path $KioskRoot 'Install-ShopdbKiosk.ps1'
if (-not (Test-Path $installer)) {
Log "Installer not staged at $installer. Leaving armed."
return
}
# --- is ShopDB reachable yet? -------------------------------------------
# Expected to fail until the bay joins AESFMA. That is the whole reason this
# script exists, so a failure here is logged quietly and retried, not raised.
$probe = "$($BaseUrl.TrimEnd('/'))/api/docs"
try {
$r = Invoke-WebRequest -Uri $probe -UseBasicParsing -TimeoutSec $TimeoutSeconds -EA Stop
Log "ShopDB reachable (HTTP $($r.StatusCode))."
} catch {
$code = try { $_.Exception.Response.StatusCode.value__ } catch { $null }
if ($code) {
# Answered at all = reachable. 401/403 just means no token on this probe.
Log "ShopDB reachable (HTTP $code)."
} else {
Log "Not reachable yet - waiting for AESFMA. ($($_.Exception.Message))"
return
}
}
# --- keys ----------------------------------------------------------------
$keyFile = Join-Path $KioskRoot 'shopdb-key.txt'
$collectorKey = ''
$fetchToken = ''
if (Test-Path $keyFile) {
# LABELLED format, because the two tokens are not interchangeable and a
# positional mix-up is silent: a fetch token in the collector slot leaves
# asset reporting broken while everything looks configured.
#
# collector=<collector.ingest PAT> always needed for asset reporting
# fetch=<geenforce.fetch PAT> optional when the subnet is allowlisted
#
# A bare single line is REJECTED rather than guessed at.
foreach ($line in (Get-Content $keyFile -EA SilentlyContinue)) {
$t = $line.Trim()
if (-not $t -or $t.StartsWith('#')) { continue }
if ($t -match '^(?i)collector\s*=\s*(.+)$') { $collectorKey = $Matches[1].Trim() }
elseif ($t -match '^(?i)fetch\s*=\s*(.+)$') { $fetchToken = $Matches[1].Trim() }
else {
Log "Key file line is not labelled 'collector=' or 'fetch=' - ignoring it rather than guessing which token it is."
}
}
Log ("Key file present (collector={0}, fetch={1})" -f `
$(if ($collectorKey) { 'yes' } else { 'no' }), $(if ($fetchToken) { 'yes' } else { 'no' }))
} else {
# Not fatal. The fetch token is unnecessary when the kiosk subnet is
# IP-allowlisted; only the asset-report task needs the collector key, and
# the installer skips that task rather than failing.
Log "No key file - installing without tokens (allowlisted subnets still work; no asset reporting)."
}
# --- run the vendor installer -------------------------------------------
$args = @{ DisplayType = $displayType; BaseUrl = $BaseUrl }
if ($collectorKey) { $args.CollectorKey = $collectorKey }
if ($fetchToken) { $args.ShopdbToken = $fetchToken }
Log "Running $installer ..."
try {
& $installer @args 2>&1 | ForEach-Object { Log " $_" }
} catch {
Log "Installer threw: $_"
return
}
# --- verify, then clean up ----------------------------------------------
$haveCfg = (Test-Path $reg) -and ((Get-ItemProperty $reg -EA SilentlyContinue).BaseUrl)
$haveTask = [bool](Get-ScheduledTask -EA SilentlyContinue |
Where-Object { $_.TaskName -match '(?i)shopdb.*enforce' })
if ($haveCfg -and $haveTask) {
Log "Install verified: BaseUrl set and enforce task registered."
if (Test-Path $keyFile) {
# The installer has moved the secrets into HKLM (SYSTEM/Admins ACL).
# The staged copy is readable by any local user until lockdown, so it
# goes now rather than lingering.
Remove-Item $keyFile -Force -EA SilentlyContinue
Log "Removed staged key file."
}
Remove-Self
} else {
Log "Installer ran but verification FAILED (BaseUrl=$haveCfg, enforceTask=$haveTask). Leaving armed to retry."
}

View File

@@ -0,0 +1,45 @@
@echo off
REM Install-eMxInfo-PartMarker.cmd - eMxInfo.txt healing for MARKER bays.
REM
REM Identical to Install-eMxInfo.cmd for the two paths that matter, plus the
REM Mark install directory.
REM
REM WHICH PATH ACTUALLY MATTERS: mxTransactionDll.dll (shipped in the Mark
REM overlay) references "\Dnc\Server Files\eMxInfo.txt", so the Mark suite
REM reads the DNC install just as eDNC does. The two DNC paths below are
REM therefore the functional ones on a marker bay too.
REM
REM WHY THE MARK PATH IS HERE AT ALL: 02-Setup-PartMarker.ps1 copies
REM eMxInfo.txt into C:\Program Files (x86)\Mark at imaging time. Nothing
REM reads it there, but leaving it stale would make a healed bay differ from a
REM freshly imaged one, and the next person to compare the two would waste an
REM afternoon on it. Copying a 1KB file is cheaper than that.
REM
REM Use this INSTEAD of Install-eMxInfo.cmd on gea-shopfloor-partmarker, or
REM simply add the marker PCType to the existing entry and accept that the Mark
REM copy goes stale - it is cosmetic either way.
rem The config lives in the scope's configs\ dir, NOT beside this file.
rem The repo copy of the collections template still says %~dp0eMxInfo.txt,
rem which is stale - the cmd actually deployed on the share uses ..\configs\.
set "SRC=%~dp0..\configs\eMxInfo.txt"
if not exist "%SRC%" (
echo Install-eMxInfo-PartMarker: source file not found at %SRC%
exit /b 1
)
set "DST64=C:\Program Files\DNC\Server Files"
set "DST86=C:\Program Files (x86)\DNC\Server Files"
set "DSTMARK=C:\Program Files (x86)\Mark"
if not exist "%DST64%\" mkdir "%DST64%" 2>nul
if not exist "%DST86%\" mkdir "%DST86%" 2>nul
copy /Y "%SRC%" "%DST64%\eMxInfo.txt" >nul || exit /b 2
copy /Y "%SRC%" "%DST86%\eMxInfo.txt" >nul || exit /b 3
REM The Mark directory only exists on a marker bay; not an error elsewhere.
if exist "%DSTMARK%\" copy /Y "%SRC%" "%DSTMARK%\eMxInfo.txt" >nul
echo Install-eMxInfo-PartMarker: deployed eMxInfo.txt to the DNC paths (and Mark, if present)
exit /b 0

View File

@@ -0,0 +1,12 @@
{
"_comment": "CORRECTED 2026-08-25. The eMxInfo entries live in the COLLECTIONS and NOCOLLECTIONS scopes, which a marker bay never evaluates - so this is a new entry in gea-shopfloor-partmarker/manifest.json, not a PCTypes edit to an existing one. The site already duplicates this per scope: each carries its own configs/eMxInfo.txt and scripts/Install-eMxInfo.cmd. Markers now do too.",
"_files": "gea-shopfloor-partmarker\\configs\\eMxInfo.txt (copied from the collections scope - site secret, never committed) and gea-shopfloor-partmarker\\scripts\\Install-eMxInfo.cmd (the variant that also refreshes the copy imaging leaves in C:\\Program Files (x86)\\Mark).",
"_ordering": "None needed inside this scope: no eDNC installer entry reaches markers through GE-Enforce, so there is nothing to be evaluated after. Hash detection fires whenever the file is missing or stale, which is what a hand reinstall of eDNC or Mark produces.",
"_rotation": "Yearly rotation of eMxInfo.txt now touches THREE scopes - collections, nocollections and partmarker. Overwrite configs/eMxInfo.txt in each, recompute the SHA256, paste it into each DetectionValue.",
"Name": "eMxInfo.txt",
"Installer": "scripts/Install-eMxInfo.cmd",
"Type": "CMD",
"DetectionMethod": "Hash",
"DetectionPath": "C:\\Program Files (x86)\\DNC\\Server Files\\eMxInfo.txt",
"DetectionValue": "87733201CB11E7343BD432F1E303FBF41DB58EBAAEFF37BD4C3C9B267B145A20"
}

View File

@@ -0,0 +1,13 @@
{
"_comment": "CORRECTED 2026-08-25 after checking the live share layout. This entry goes in common/manifest.json, NOT the partmarker scope: the script imports ..\\lib\\TelesisMarker.psm1 and ..\\lib\\ShopdbBackupClient.psm1, and those live in common\\lib. Backup-NtlarsSettings.ps1 sits in common for the same reason, and its entry already carries a PCTypes list including gea-shopfloor-partmarker. Scoping is by PCTypes, not by which directory the entry lives in.",
"_files": "common\\lib\\TelesisMarker.psm1 (new), common\\lib\\ShopdbBackupClient.psm1 (UPDATED - shared with the NTLARS backup, which uses none of the additions), common\\scripts\\Backup-PartMarker.ps1 (new). Test-ShareAccess.ps1 also goes in common\\scripts but needs no manifest entry: it is a hand-run diagnostic.",
"_schedule": "Captures run only at shift changes - 14:00 normally because someone is on site to fix a problem, 06:00 and 22:00 only once the backup is 3+ days overdue. The marker's COM port is shared with eDNC and exclusive-open, so a capture at a random hour would fail an operator's mark.",
"_settings": "ShopDB needs backups_shareroot set, and the bay needs a collector key plus either a machine number in pc-config.txt or a device asset number in C:\\Enrollment\\asset-id.txt. Missing any of these is logged once and the cycle skips.",
"Name": "Part Marker Backup to ShopDB",
"Type": "PS1",
"Script": "scripts/Backup-PartMarker.ps1",
"DetectionMethod": "Always",
"PCTypes": [
"gea-shopfloor-partmarker"
]
}

View File

@@ -0,0 +1,15 @@
{
"_comment": "Data-driven WinPE shopfloor PC-type menu. Read at boot by select-shopfloor-type.ps1 (mirrors the CMM bay picker) and edited by the PXE webapp. 'key' is the PCTYPE value - it MUST match a shopfloor-setup/gea-shopfloor-<x> handler dir. Order = display order. Set enabled=false to hide without deleting. Deployed to the enrollment share; startnet.cmd falls back to its baked-in menu if this file or the picker is unavailable.",
"shopfloor": [
{ "key": "gea-shopfloor-collections", "label": "Machine with Collections", "hint": "eDNC + UDC + Plant Apps", "enabled": true },
{ "key": "gea-shopfloor-nocollections", "label": "Machine without Collections", "hint": "eDNC + Plant Apps, no UDC", "enabled": true },
{ "key": "gea-shopfloor-common", "label": "Common", "hint": "Timeclock, Lab; WJ Shopfloor only", "enabled": true },
{ "key": "gea-shopfloor-keyence", "label": "Keyence", "hint": "VR-3000 / VR-5000 / VR-6000 microscope","enabled": true },
{ "key": "gea-shopfloor-cmm", "label": "CMM", "hint": "Hexagon PC-DMIS + Protect Viewer", "enabled": true },
{ "key": "gea-shopfloor-genspect", "label": "Genspect", "hint": "", "enabled": true },
{ "key": "gea-shopfloor-heattreat", "label": "Heattreat", "hint": "eDNC + HeatTreat app", "enabled": true },
{ "key": "gea-shopfloor-waxtrace", "label": "Wax and Trace", "hint": "", "enabled": true },
{ "key": "gea-shopfloor-display", "label": "Display", "hint": "kiosk dashboard", "enabled": true },
{ "key": "gea-shopfloor-partmarker", "label": "Part Marker", "hint": "eDNC + Telesis Mark", "enabled": true }
]
}

View File

@@ -19,6 +19,18 @@ function Log {
Log "=== GE Aerospace GCCH Enrollment ==="
# --- Run once ------------------------------------------------------------
# This script has been observed running twice on one build (579C144,
# 2026-08-06: 13:31:55 and 13:36:45). The second provtool call returned
# 0x800700B7 ERROR_ALREADY_EXISTS and achieved nothing except re-applying a
# pending computer rename over the one the package had just set. The rename is
# gone now, but a second full pass is still pointless work on an 8 GB package.
$appliedMarker = 'C:\Enrollment\.ppkg-applied'
if (Test-Path $appliedMarker) {
Log "Provisioning package already applied on $(Get-Content $appliedMarker -First 1) - skipping."
return
}
# --- Find the .ppkg ---
$ppkgFile = Get-ChildItem "C:\Enrollment\*.ppkg" -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $ppkgFile) {
@@ -27,11 +39,22 @@ if (-not $ppkgFile) {
}
Log "Package: $($ppkgFile.Name)"
# --- Set computer name to E<serial> ---
$serial = (Get-CimInstance Win32_BIOS).SerialNumber
$newName = "E$serial"
Log "Setting computer name to $newName"
Rename-Computer -NewName $newName -Force -ErrorAction SilentlyContinue
# --- Computer name: the PPKG owns it, do NOT rename here ---
# The package declares <DNSComputerName>F%SERIAL%</DNSComputerName>, so a bay is
# meant to come up as F<serial>. This script used to run
# Rename-Computer -NewName "E$serial"
# which is a second pending rename racing the package's. Observed on 579C144
# 2026-08-06:
#
# 13:31:55 run-enrollment Rename-Computer -> E579C144 (pending)
# 13:32:17 ppkg Reboot/DeviceName -> F%SERIAL% (pending)
# "Reboot successfully scheduled. Device name set successfully."
# 13:36:45 run-enrollment runs AGAIN -> E579C144 (overwrites F)
# ~13:37 reboot, E579C144 wins
#
# Last writer wins at reboot, so the hardcoded E prefix silently beat the
# package's F. Nothing in this repo ever produced an F name; the convention only
# works if we leave naming to the package.
# --- Install provisioning package ---
# IMPORTANT: The PPKG must be installed BEFORE OOBEComplete is set. Bulk
@@ -83,9 +106,20 @@ Log "PPKG diagnostic logs -> $ppkgLogDir (provtool writes them automatically)"
try {
$p = Start-Process -FilePath $provtool -ArgumentList $provArgs -Wait -PassThru -NoNewWindow -ErrorAction Stop
Log "provtool.exe exit code: $($p.ExitCode)"
if ($p.ExitCode -ne 0) {
if ($p.ExitCode -eq 0) {
Set-Content -Path $appliedMarker -Value (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') -ErrorAction SilentlyContinue
Log "Wrote applied-marker $appliedMarker"
} else {
$hex = '0x{0:X8}' -f $p.ExitCode
Log "WARNING: provtool.exe returned non-zero exit code ($hex). Check $ppkgLogDir for diagnostic bundle."
# 0x800700B7 ERROR_ALREADY_EXISTS means the package is already applied.
# That is a success for our purposes, and it is what a second run of
# this script produced on 579C144 (2026-08-06) - so mark it applied and
# let the next run skip instead of repeating the work.
if ($p.ExitCode -eq -2147024713) {
Set-Content -Path $appliedMarker -Value (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') -ErrorAction SilentlyContinue
Log "Package was already applied (ERROR_ALREADY_EXISTS) - marker written."
}
}
} catch {
Log "ERROR: Failed to launch provtool.exe: $_"

View File

@@ -0,0 +1,63 @@
<#
select-shopfloor-type.ps1 - data-driven GEA Shopfloor PC-type picker for WinPE.
Reads the shopfloor menu from menu.json (edited by the PXE webapp), renders a
numbered menu, and writes the chosen PCTYPE key (e.g. gea-shopfloor-cmm) to
-OutFile. startnet.cmd runs this instead of a hardcoded menu, and falls back
to its baked-in menu if this script or menu.json is missing. Mirrors the CMM
bay picker (select-cmm-bay.ps1).
Usage (from startnet.cmd):
powershell -NoProfile -ExecutionPolicy Bypass -File select-shopfloor-type.ps1 `
-MenuJson Y:\menu.json -OutFile X:\pctype.txt
Exit 0 + a written key on success; non-zero (and no file) on any failure so
the batch fallback kicks in.
#>
param(
[Parameter(Mandatory = $true)][string]$MenuJson,
[Parameter(Mandatory = $true)][string]$OutFile
)
$ErrorActionPreference = 'Stop'
Remove-Item -LiteralPath $OutFile -ErrorAction SilentlyContinue
try {
if (-not (Test-Path -LiteralPath $MenuJson)) { Write-Host "menu.json not found: $MenuJson"; exit 2 }
$data = Get-Content -LiteralPath $MenuJson -Raw | ConvertFrom-Json
$items = @($data.shopfloor | Where-Object { $_.key -and ($_.enabled -ne $false) })
if ($items.Count -eq 0) { Write-Host "menu.json has no enabled shopfloor items"; exit 3 }
while ($true) {
Clear-Host
Write-Host ""
Write-Host "========================================"
Write-Host " GEA Shopfloor PC Sub-Type"
Write-Host "========================================"
Write-Host ""
for ($i = 0; $i -lt $items.Count; $i++) {
$n = $i + 1
$lbl = $items[$i].label
$hint = $items[$i].hint
$line = "{0,3}. {1}" -f $n, $lbl
if ($hint) { $line = "{0,-45} ({1})" -f $line, $hint }
Write-Host $line
}
Write-Host ""
$sel = Read-Host ("Enter your choice (1-{0})" -f $items.Count)
$num = 0
if ([int]::TryParse($sel, [ref]$num) -and $num -ge 1 -and $num -le $items.Count) {
$key = $items[$num - 1].key
# OutFile is on X: (WinPE scratch); write the bare PCTYPE key, no BOM/newline surprises
[System.IO.File]::WriteAllText($OutFile, $key)
Write-Host ""
Write-Host ("Selected: {0} ({1})" -f $items[$num - 1].label, $key)
exit 0
}
Write-Host "Invalid choice - try again."
Start-Sleep -Milliseconds 800
}
}
catch {
Write-Host "shopfloor picker error: $($_.Exception.Message)"
exit 1
}

View File

@@ -9,7 +9,7 @@
"urls": {
"plantApps": "https://mes-wjefferson.apps.lr.geaerospace.net/run/?app_name=Plant%20Applications",
"shopFloorHomepage": "https://tsgwp00525.wjs.geaerospace.net",
"shopfloorDashboard": "https://tsgwp00525.wjs.geaerospace.net/shopdb/shopfloor-dashboard/",
"shopfloorDashboard": "https://tsgwp00525.wjs.geaerospace.net/shopdb/shopfloor",
"webmail": "https://outlook.office365.us/mail"
},
@@ -190,6 +190,7 @@
{ "name": "Defect_Tracker", "lnkPath": "%PUBLIC%\\Desktop\\Shopfloor Tools\\Defect_Tracker.lnk" },
{ "name": "PC-DMIS 2016", "lnkPath": "%PUBLIC%\\Desktop\\Shopfloor Tools\\PC-DMIS 2016.lnk" },
{ "name": "PC-DMIS 2019 R2", "lnkPath": "%PUBLIC%\\Desktop\\Shopfloor Tools\\PC-DMIS 2019 R2.lnk" },
{ "name": "PC-DMIS 2026.1", "lnkPath": "%PUBLIC%\\Desktop\\Shopfloor Tools\\PC-DMIS 2026.1.lnk" },
{ "name": "CLM Admin", "lnkPath": "%PUBLIC%\\Desktop\\Shopfloor Tools\\CLM Admin.lnk" },
{ "name": "goCMM", "lnkPath": "%PUBLIC%\\Desktop\\Shopfloor Tools\\goCMM.lnk" }
],
@@ -198,6 +199,7 @@
{ "name": "Defect_Tracker", "kind": "existing", "sourceName": "Defect_Tracker.lnk" },
{ "name": "PC-DMIS 2016", "kind": "exe", "exePath": "C:\\Program Files\\Hexagon\\PC-DMIS 2016.0 64-bit\\PCDLRN.exe" },
{ "name": "PC-DMIS 2019 R2", "kind": "exe", "exePath": "C:\\Program Files\\Hexagon\\PC-DMIS 2019 R2 64-bit\\PCDLRN.exe" },
{ "name": "PC-DMIS 2026.1", "kind": "exe", "exePath": "C:\\Program Files\\Hexagon\\PC-DMIS 2026.1 64-bit\\PCDLRN.exe" },
{ "name": "CLM Admin", "kind": "exe", "exePath": "C:\\Program Files\\Hexagon\\CLM Admin 1.8 64-bit\\clmadmin.exe" },
{ "name": "goCMM", "kind": "exe", "exePath": "C:\\Program Files (x86)\\General Electric\\goCMM\\goCMM.exe" }
],
@@ -303,7 +305,7 @@
{ "name": "Microsoft Edge", "lnkPath": "%ALLUSERSPROFILE%\\Microsoft\\Windows\\Start Menu\\Programs\\Microsoft Edge.lnk" }
],
"desktopApps": [],
"edgeHomepage": "https://tsgwp00525.wjs.geaerospace.net/shopdb/shopfloor-dashboard/",
"edgeHomepage": "https://tsgwp00525.wjs.geaerospace.net/shopdb/tv",
"edgeStartupTabs": [
{ "baseName": "Shopfloor Dashboard", "fallbackUrlKey": "shopfloorDashboard" }
]
@@ -316,7 +318,7 @@
{ "name": "Microsoft Edge", "lnkPath": "%ALLUSERSPROFILE%\\Microsoft\\Windows\\Start Menu\\Programs\\Microsoft Edge.lnk" }
],
"desktopApps": [],
"edgeHomepage": "https://tsgwp00525.wjs.geaerospace.net/shopdb/shopfloor-dashboard/",
"edgeHomepage": "https://tsgwp00525.wjs.geaerospace.net/shopdb/shopfloor",
"edgeStartupTabs": [
{ "baseName": "Shopfloor Dashboard", "fallbackUrlKey": "shopfloorDashboard" }
]

View File

@@ -3,10 +3,21 @@ echo Please wait while 'WinPE' is being processed. This may take a few seconds.
wpeinit
powercfg /s 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c
REM --- Site values -----------------------------------------------------
REM The PXE server address and share credentials appeared 14 and 13 times
REM respectively, so moving this server or rotating the account meant editing
REM every net use in the file and rebuilding boot.wim. Set them once here.
REM These are also written to C:\Enrollment\fetch-source.txt, which
REM Fetch-StagingPayload.ps1 and Verify-And-Heal-Staging.ps1 read at first
REM logon - so the post-boot self-heal follows this value automatically.
set PXESERVER=172.16.9.1
set SHAREUSER=pxe-upload
set SHAREPASS=pxe
REM --- Wait for network (DHCP may take a moment after wpeinit) ---
echo Waiting for network...
:wait_net
ping -n 2 172.16.9.1 >NUL 2>&1
ping -n 2 %PXESERVER% >NUL 2>&1
if errorlevel 1 goto wait_net
echo Network ready.
@@ -17,7 +28,7 @@ REM CALLed scripts inside parens does not propagate BIOS_STATUS back to
REM this script reliably. Use goto-flow instead so the CALL runs at the
REM top scope and BIOS_STATUS persists.
set BIOS_STATUS=No BIOS check (share unavailable)
net use B: \\172.16.9.1\winpeapps_bios /user:pxe-upload pxe /persistent:no 2>NUL
net use B: \\%PXESERVER%\winpeapps_bios /user:%SHAREUSER% %SHAREPASS% /persistent:no 2>NUL
if not exist B:\check-bios.cmd goto :bios_check_done
echo.
echo Checking for BIOS updates...
@@ -38,26 +49,85 @@ echo.
echo 1. GEA Standard
echo 2. GEA Engineer
echo 3. GEA Shopfloor (sub-menu)
echo 4. GE Standard
echo 5. GE Engineer
echo 6. GE Shopfloor Lockdown
echo 7. GE Shopfloor MCE
echo.
echo ========================================
echo.
set /p choice=Enter your choice (1-7):
set /p choice=Enter your choice (1-3):
REM --- Shopfloor images (3,6,7) need GCCH enrollment + (for 3) PC-type sub-menu.
REM Choice 3 = GEA Shopfloor: drill into sub-menu first to pick the gea-shopfloor-*
REM sub-category, THEN the office menu, THEN machine number for collections+nocollections.
set PPKG=
set PCCONFIG=
if "%choice%"=="3" goto gea_shopfloor_submenu
if "%choice%"=="6" goto enroll_menu
if "%choice%"=="7" goto enroll_menu
REM Standard and engineer enrol through the vendor orchestrator, which asks for
REM tenant + PC purpose with a GUI at first logon. Ask here instead, while the
REM tech who knows what the machine is for is standing at it, and pre-seed the
REM answer so the dialog never appears. See :gea_purpose_submenu.
if "%choice%"=="1" goto gea_purpose_submenu
if "%choice%"=="2" goto gea_purpose_submenu
goto enroll_staged
:gea_purpose_submenu
cls
echo.
echo ========================================
echo PC Purpose - Entra enrollment
echo ========================================
echo.
echo 1. Single-User PC (one assigned person)
echo 2. Shared PC (multiple users)
echo 3. Loaner PC (temporary assignment)
echo.
echo 4. Ask at first logon (show the vendor dialog instead)
echo.
set PCPURPOSE=
set /p purp_choice=Enter your choice (1-4):
if "%purp_choice%"=="1" set PCPURPOSE=PERS
if "%purp_choice%"=="2" set PCPURPOSE=SH
if "%purp_choice%"=="3" set PCPURPOSE=LOAN
if "%purp_choice%"=="4" goto enroll_staged
if "%PCPURPOSE%"=="" goto gea_purpose_submenu
:gea_tenant_submenu
cls
echo.
echo ========================================
echo Entra Tenant
echo ========================================
echo.
echo 1. GCC High (GCCH - US)
echo 2. Rest of World (RoW)
echo.
REM The tenant is not cosmetic: Insert-BPRTToPPKG.ps1 matches the bulk token by
REM Tenant AND Purpose, so this picks which token gets injected.
set PCTENANT=
set /p ten_choice=Enter your choice (1-2):
if "%ten_choice%"=="1" set PCTENANT=GCCH
if "%ten_choice%"=="2" set PCTENANT=RoW
if "%PCTENANT%"=="" goto gea_tenant_submenu
set PCCONFIG=%PCTENANT%_%PCPURPOSE%
echo.
echo PC config: %PCCONFIG%
goto enroll_staged
:gea_shopfloor_submenu
cls
set PCTYPE=
REM Data-driven PC-type menu: the PXE webapp maintains menu.json on the
REM enrollment share; select-shopfloor-type.ps1 renders it and writes the
REM chosen PCTYPE to X:\pctype.txt. Falls back to the baked-in menu below if
REM the share or the picker is unavailable (mirrors the CMM bay picker).
net use Y: \\%PXESERVER%\enrollment /user:%SHAREUSER% %SHAREPASS% /persistent:no >NUL 2>NUL
del X:\pctype.txt 2>NUL
if exist "Y:\shopfloor-setup\select-shopfloor-type.ps1" powershell.exe -NoProfile -ExecutionPolicy Bypass -File "Y:\shopfloor-setup\select-shopfloor-type.ps1" -MenuJson "Y:\shopfloor-setup\menu.json" -OutFile "X:\pctype.txt"
if exist X:\pctype.txt set /p PCTYPE=<X:\pctype.txt
if not "%PCTYPE%"=="" goto gea_shopfloor_have_type
:gea_shopfloor_submenu_fallback
cls
echo.
echo ========================================
echo GEA Shopfloor PC Sub-Type
@@ -74,7 +144,6 @@ echo 8. Wax and Trace
echo 9. Display (kiosk dashboard)
echo 10. Part Marker (eDNC + Telesis Mark)
echo.
set PCTYPE=
set /p ges_choice=Enter your choice (1-10):
if "%ges_choice%"=="1" set PCTYPE=gea-shopfloor-collections
if "%ges_choice%"=="2" set PCTYPE=gea-shopfloor-nocollections
@@ -86,7 +155,9 @@ if "%ges_choice%"=="7" set PCTYPE=gea-shopfloor-heattreat
if "%ges_choice%"=="8" set PCTYPE=gea-shopfloor-waxtrace
if "%ges_choice%"=="9" set PCTYPE=gea-shopfloor-display
if "%ges_choice%"=="10" set PCTYPE=gea-shopfloor-partmarker
if "%PCTYPE%"=="" goto gea_shopfloor_submenu
if "%PCTYPE%"=="" goto gea_shopfloor_submenu_fallback
:gea_shopfloor_have_type
if "%PCTYPE%"=="gea-shopfloor-display" goto display_submenu
if "%PCTYPE%"=="gea-shopfloor-keyence" goto keyence_submenu
if "%PCTYPE%"=="gea-shopfloor-cmm" goto cmm_submenu
@@ -121,7 +192,7 @@ echo ========================================
echo.
echo Loading CMM bay list from PXE share...
REM Mount enrollment share early so the picker can read the CSV.
net use Y: \\172.16.9.1\enrollment /user:pxe-upload pxe /persistent:no >NUL 2>NUL
net use Y: \\%PXESERVER%\enrollment /user:%SHAREUSER% %SHAREPASS% /persistent:no >NUL 2>NUL
del X:\cmm-bay.txt 2>NUL
set CMMID=
set CMMVARIANT=standard
@@ -139,8 +210,8 @@ if "%CMMID%"=="" (
goto enroll_menu
)
echo CMM bay: %CMMID%
REM Resolve bay config (writes version.txt + doda.txt to W:\Enrollment\cmm\
REM after W: appears). Resolution deferred to after imaging starts - store
REM Resolve bay config (writes version.txt + doda.txt to %OSDRIVE%\Enrollment\cmm\
REM after %OSDRIVE% appears). Resolution deferred to after imaging starts - store
REM the CMMID for now and resolve in the post-imaging copy block below.
goto enroll_menu
@@ -153,11 +224,21 @@ echo ========================================
echo.
echo 1. Dashboard (shop floor metrics dashboard)
echo 2. Lobby Display (lobby information screen)
echo 3. 3D Parts Kiosk (printed-parts kiosk)
echo.
REM These values are KEYS into the GE-Enforce display scope
REM (shopdb-flask plugins/geenforce/seed_display_scope.py):
REM Dashboard -> /shopdb/shopfloor, Lobby -> /shopdb/tv,
REM 3DPrintRoom -> /shopdb/parts-kiosk
REM They are written to C:\Enrollment\display-type.txt and the dispatcher reads
REM that as its fallback when the server has no IP-based role for the device, so
REM the spelling must match the map exactly. 3DPrintRoom was in the map but had
REM no menu entry, so a parts kiosk could only be set by hand after imaging.
set DISPLAYTYPE=
set /p disp_choice=Enter your choice (1-2):
set /p disp_choice=Enter your choice (1-3):
if "%disp_choice%"=="1" set DISPLAYTYPE=Dashboard
if "%disp_choice%"=="2" set DISPLAYTYPE=Lobby
if "%disp_choice%"=="3" set DISPLAYTYPE=3DPrintRoom
if "%DISPLAYTYPE%"=="" goto display_submenu
goto enroll_menu
@@ -180,10 +261,25 @@ set /p enroll=Enter your choice (1-6):
REM --- PPKG configuration (constructed at menu time, see docs) ---
REM Vendor ships one source PPKG; we construct the BPRT-tagged filename
REM by filling in Office, Region, Expiry, Version on the target copy.
REM Update SOURCE_PPKG + PPKG_VER when a new PPKG is released.
set SOURCE_PPKG=GCCH_Prod_SFLD_v4.16.ppkg
set PPKG_VER=v4.16
set PPKG_EXP=20260831
REM The ACTIVE ppkg is read from \\172.16.9.1\enrollment\ppkg.conf, which the
REM PXE webapp writes on upload - so a new ppkg goes live with no startnet edit.
REM The baked-in values below are only a fallback if ppkg.conf is missing.
net use Y: \\%PXESERVER%\enrollment /user:%SHAREUSER% %SHAREPASS% /persistent:no >nul 2>&1
set SOURCE_PPKG=
set TARGET_PPKG=
set PPKG_VER=
set PPKG_EXP=
if exist "Y:\ppkg.conf" (
for /f "usebackq tokens=1,* delims==" %%A in ("Y:\ppkg.conf") do (
if /i "%%A"=="SOURCE_PPKG" set "SOURCE_PPKG=%%B"
if /i "%%A"=="PPKG_VER" set "PPKG_VER=%%B"
if /i "%%A"=="PPKG_EXP" set "PPKG_EXP=%%B"
if /i "%%A"=="TARGET_PPKG" set "TARGET_PPKG=%%B"
)
)
if "%SOURCE_PPKG%"=="" set SOURCE_PPKG=GCCH_Prod_SFLD_v4.16.ppkg
if "%PPKG_VER%"=="" set PPKG_VER=v4.16
if "%PPKG_EXP%"=="" set PPKG_EXP=20260831
set REGION=US
set OFFICE=
@@ -196,7 +292,32 @@ if "%enroll%"=="6" set OFFICE=
if "%enroll%"=="" goto enroll_menu
set PPKG=
if not "%OFFICE%"=="" set PPKG=GCCH_Prod_SFLD_%OFFICE%_%REGION%_Exp_%PPKG_EXP%_%PPKG_VER%.ppkg
REM Target filename for the staged package.
REM
REM The SFLD-era scheme encoded an Office variant in the name
REM (GCCH_Prod_SFLD_<Office>_<Region>_Exp_<date>_<ver>.ppkg) because one source
REM package was renamed per Office choice. MCL packages do not work that way -
REM Office is installed by Install-Office.ps1 inside the package - and renaming
REM an MCL package into the SFLD scheme just produces a misleading filename.
REM
REM So: honour TARGET_PPKG from ppkg.conf when present, otherwise keep the
REM source filename unchanged. The SFLD construction remains only as a fallback
REM for a legacy SFLD source package.
if not "%TARGET_PPKG%"=="" (
set PPKG=%TARGET_PPKG%
) else (
REM find, NOT findstr: findstr.exe is not in this WinPE image (find.exe is,
REM and startnet already uses it for the PESetup wait). findstr fails with
REM "not recognized", leaves errorlevel 9009, and "if errorlevel 1" then
REM takes the NON-SFLD branch for an SFLD package - so the staged ppkg keeps
REM its source name and never gets the BPRT-tagged one.
echo %SOURCE_PPKG% | find /I "_SFLD_" >NUL
if errorlevel 1 (
set PPKG=%SOURCE_PPKG%
) else (
if not "%OFFICE%"=="" set PPKG=GCCH_Prod_SFLD_%OFFICE%_%REGION%_Exp_%PPKG_EXP%_%PPKG_VER%.ppkg
)
)
REM --- 2026-05-04 rename reorg: PCTYPE is set by gea_shopfloor_submenu above
REM (single string, e.g. gea-shopfloor-collections). pc-subtype.txt is no
@@ -229,7 +350,7 @@ echo.
echo Loading Wax/Trace bay list from PXE share...
REM Mount enrollment share early so the picker can read INDEX.csv. Later
REM net use Y: will be a no-op if Y: is already mapped.
net use Y: \\172.16.9.1\enrollment /user:pxe-upload pxe /persistent:no >NUL 2>NUL
net use Y: \\%PXESERVER%\enrollment /user:%SHAREUSER% %SHAREPASS% /persistent:no >NUL 2>NUL
del X:\waxtrace-asset.txt 2>NUL
if not exist "Y:\installers-post\waxtrace\select-waxtrace-asset.ps1" goto waxtrace_picker_skip
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "Y:\installers-post\waxtrace\select-waxtrace-asset.ps1" -IndexPath "Y:\installers-post\waxtrace\bay-config.csv" -OutFile "X:\waxtrace-asset.txt"
@@ -257,7 +378,7 @@ if not "%PPKG%"=="" set NEED_ENROLL=1
if not "%PCTYPE%"=="" set NEED_ENROLL=1
if "%NEED_ENROLL%"=="0" goto enroll_staged
if exist Y:\ goto enroll_already_mapped
net use Y: \\172.16.9.1\enrollment /user:pxe-upload pxe /persistent:no
net use Y: \\%PXESERVER%\enrollment /user:%SHAREUSER% %SHAREPASS% /persistent:no
:enroll_already_mapped
if "%PPKG%"=="" goto enroll_staged
if not exist "Y:\ppkgs\%SOURCE_PPKG%" (
@@ -270,10 +391,6 @@ echo. > X:\Boot.tag
if "%choice%"=="1" goto gea-standard
if "%choice%"=="2" goto gea-engineer
if "%choice%"=="3" goto gea-shopfloor
if "%choice%"=="4" goto ge-standard
if "%choice%"=="5" goto ge-engineer
if "%choice%"=="6" goto ge-shopfloor-lockdown
if "%choice%"=="7" goto ge-shopfloor-mce
echo Invalid choice. Please try again.
pause
goto menu
@@ -283,7 +400,7 @@ echo.
echo Starting GEA Standard setup...
start "FlatApp" %SYSTEMDRIVE%\GESetup\FlatSetupLoader.exe
for /l %%i in (1,1,2000000) do rem
net use Z: \\172.16.9.1\winpeapps\gea-standard /user:pxe-upload pxe /persistent:no
net use Z: \\%PXESERVER%\winpeapps\gea-standard /user:%SHAREUSER% %SHAREPASS% /persistent:no
goto end
:gea-engineer
@@ -291,7 +408,7 @@ echo.
echo Starting GEA Engineer setup...
start "FlatApp" %SYSTEMDRIVE%\GESetup\FlatSetupLoader.exe
for /l %%i in (1,1,2000000) do rem
net use Z: \\172.16.9.1\winpeapps\gea-engineer /user:pxe-upload pxe /persistent:no
net use Z: \\%PXESERVER%\winpeapps\gea-engineer /user:%SHAREUSER% %SHAREPASS% /persistent:no
goto end
:gea-shopfloor
@@ -299,39 +416,34 @@ echo.
echo Starting GEA Shopfloor setup...
start "FlatApp" %SYSTEMDRIVE%\GESetup\FlatSetupLoader.exe
for /l %%i in (1,1,2000000) do rem
net use Z: \\172.16.9.1\winpeapps\gea-shopfloor /user:pxe-upload pxe /persistent:no
goto end
:ge-standard
REM --- Per-PCTYPE media view -----------------------------------------------
REM PESetup's CopyPackages copies the WHOLE of <media>\Deploy\Applications to
REM the target, recursively, with no filtering and no manifest (see
REM docs/PESETUP-INTERNALS.md). One shared shopfloor media therefore hands
REM every shopfloor payload to every bay whatever its type.
REM _media\<PCTYPE> is that same media rebuilt out of symlinks by
REM scripts/build-pctype-media.py, carrying only this type's payload. It is
REM built once, server-side, and never mutated per session: bays image
REM concurrently and CopyPackages is fail-fast, so changing a path mid-copy
REM would fail some OTHER bay's imaging.
if "%PCTYPE%"=="" goto gea_shopfloor_media_shared
REM Free Z: first. A leftover mapping makes net use fail with "device already
REM in use" and the probe below would then pass against the OLD media.
net use Z: /delete /y >NUL 2>&1
net use Z: \\%PXESERVER%\winpeapps\_media\%PCTYPE% /user:%SHAREUSER% %SHAREPASS% /persistent:no >NUL 2>&1
REM Probe a file that only resolves when the view exists AND its symlinks are
REM intact. A dangling view must not image silently off a half-mapped drive.
if exist "Z:\Deploy\Control\HardwareDriver.json" goto end
echo.
echo Starting GE Standard setup...
start "FlatApp" %SYSTEMDRIVE%\GESetup\FlatSetupLoader.exe
for /l %%i in (1,1,2000000) do rem
net use Z: \\172.16.9.1\winpeapps\ge-standard /user:pxe-upload pxe /persistent:no
goto end
:ge-engineer
echo WARNING: no usable per-type media at _media\%PCTYPE%.
echo Falling back to the shared shopfloor media, so EVERY shopfloor
echo payload will be staged on this bay, not just this type's.
echo Imaging is otherwise unaffected. Tell the PXE admin.
echo.
echo Starting GE Engineer setup...
start "FlatApp" %SYSTEMDRIVE%\GESetup\FlatSetupLoader.exe
for /l %%i in (1,1,2000000) do rem
net use Z: \\172.16.9.1\winpeapps\ge-engineer /user:pxe-upload pxe /persistent:no
goto end
net use Z: /delete /y >NUL 2>&1
:ge-shopfloor-lockdown
echo.
echo Starting GE Shopfloor Lockdown setup...
start "FlatApp" %SYSTEMDRIVE%\GESetup\FlatSetupLoader.exe
for /l %%i in (1,1,2000000) do rem
net use Z: \\172.16.9.1\winpeapps\ge-shopfloor-lockdown /user:pxe-upload pxe /persistent:no
goto end
:ge-shopfloor-mce
echo.
echo Starting GE Shopfloor MCE setup...
start "FlatApp" %SYSTEMDRIVE%\GESetup\FlatSetupLoader.exe
for /l %%i in (1,1,2000000) do rem
net use Z: \\172.16.9.1\winpeapps\ge-shopfloor-mce /user:pxe-upload pxe /persistent:no
:gea_shopfloor_media_shared
net use Z: \\%PXESERVER%\winpeapps\gea-shopfloor /user:%SHAREUSER% %SHAREPASS% /persistent:no
goto end
:end
@@ -345,6 +457,22 @@ if exist "Y:\scripts\winpe-status-push.ps1" (
powershell -NoProfile -ExecutionPolicy Bypass -File "Y:\scripts\winpe-status-push.ps1"
)
REM --- Pre-imaging checks ---------------------------------------------------
REM Checks the things PESetup fails on, before it fails on them: secure boot
REM (hard failure at GatherData), disk >= 120 GB, a driver pack matching THIS
REM model, and media age. The driver check is the one that earns its keep - a
REM miss is only a WARNING to PESetup, so the bay images with no NIC and the
REM symptom shows up much later as a DNS failure during enrollment.
REM Advisory: it reports and pauses on a blocker, it does not abort. The tech
REM decides. Lives on the share so it can be fixed without a boot.wim rebuild.
if exist "Y:\scripts\preflight.ps1" (
powershell -NoProfile -ExecutionPolicy Bypass -File "Y:\scripts\preflight.ps1" -MediaDrive Z:
if errorlevel 1 (
echo Press any key to image anyway, or power off the bay to stop.
pause >NUL
)
)
echo Waiting for PESetup.exe to start...
:wait_start
ping -n 3 127.0.0.1 >NUL
@@ -353,27 +481,99 @@ if errorlevel 1 goto wait_start
echo PESetup.exe is running. Waiting for imaging to complete...
REM --- Copy enrollment package and shopfloor setup as soon as Windows partition appears ---
if "%PPKG%"=="" if "%PCTYPE%"=="" goto wait_finish
echo Waiting for Windows partition at W: ...
REM PCCONFIG is here so standard/engineer builds still reach the volume wait:
REM they select no ppkg and no PCTYPE, but they do need pc-config.txt written.
if "%PPKG%"=="" if "%PCTYPE%"=="" if "%PCCONFIG%"=="" goto wait_finish
REM --- Wait for the volume PESetup applies Windows to -----------------------
REM PESetup hardcodes W: in nine places - every copy destination, both DISM
REM offline sessions, bcdboot and reagentc - and CREATES it during its own disk
REM preparation. See docs/PESETUP-INTERNALS.md. There is nothing to search for.
REM
REM Do NOT try to locate the volume earlier by scanning for a Windows install.
REM Proven on 579C144, 2026-08-06, PESetup 4.0.0.20:
REM 11:30:07 PESetup logs the OLD install still mounted at C:
REM 11:30:18 operator presses Next
REM 11:30:20 PESetup runs DISKPART /S X:\diskpartEFI0.txt - the wipe
REM 11:30:24 a volume scan had returned that C:, startnet assigned it W: and
REM staged into the partition PESetup was mid-way through erasing.
REM Every robocopy died, "The system cannot find the path
REM specified", and NOTHING was staged.
REM 11:30:30 PESetup creates its own W: and starts copying to it
REM The SYSTEM hive is the signal that distinguishes the applied image from the
REM one being wiped: it exists only once the WIM apply has written it. Do not
REM weaken this test to "the volume exists" or "Windows is present".
set OSDRIVE=
echo Waiting for the applied Windows volume at W: ...
set OSWAIT=0
:wait_enroll
if exist W:\Windows\System32\config\system (
set OSDRIVE=W:
goto found_os
)
set /a OSWAIT+=1
REM 270 polls at ~10s = 45 minutes. Was 120 (20 min), which is fine on NVMe but
REM tight on slow media - a WIM apply can run 25+ minutes there, and the failure
REM mode is the scary os_not_found banner plus nothing staged. Waiting longer
REM costs nothing: the loop exits the moment the SYSTEM hive appears.
if %OSWAIT% GEQ 270 goto os_not_found
ping -n 11 127.0.0.1 >NUL
if not exist W:\Windows\System32\config\system goto wait_enroll
echo Found Windows at W:
mkdir W:\Enrollment 2>NUL
goto wait_enroll
:os_not_found
echo.
echo ******************************************************************
echo ERROR: no applied Windows volume found.
echo NOTHING was staged: pc-type.txt, the enrollment package and
echo shopfloor-setup are all missing. Imaging will finish but the
echo post-install setup will NOT run. Tell the PXE admin.
echo ******************************************************************
echo.
goto cleanup_enroll
:found_os
echo Found Windows at %OSDRIVE%
REM No drive-letter reassignment here. PESetup owns W: and runs its own DISKPART
REM against the same disk; a second diskpart racing it destroys the volume that
REM staging is writing to. The unattend's hardcoded W:\Drivers path is PESetup's
REM own destination (CopyDrivers unzips there), so it lines up by construction.
mkdir %OSDRIVE%\Enrollment 2>NUL
REM Log all copy operations to a file that persists on the target disk.
REM Tech can check C:\Enrollment\winpe-staging.log post-imaging.
REM Each echo in the copy block below also appends to this file via
REM the >> redirect after the screen echo. robocopy /LOG+ appends its
REM own output directly.
set STAGELOG=W:\Enrollment\winpe-staging.log
set STAGELOG=%OSDRIVE%\Enrollment\winpe-staging.log
echo [%DATE% %TIME%] WinPE staging started >> "%STAGELOG%"
echo PCTYPE=%PCTYPE% PPKG=%PPKG% MACHINENUM=%MACHINENUM% CMMID=%CMMID% >> "%STAGELOG%"
echo PCTYPE=%PCTYPE% PPKG=%PPKG% MACHINENUM=%MACHINENUM% CMMID=%CMMID% PCCONFIG=%PCCONFIG% >> "%STAGELOG%"
REM --- Pre-seed the tenant + PC purpose for the enrollment orchestrator -----
REM Start-BulkEnrollOrchestrator.ps1 asks for this with a GUI at first logon
REM (Select-PCConfig.ps1) and uses the answer twice: to pick which MCL package
REM to apply, and to select which bulk token Insert-BPRTToPPKG.ps1 injects -
REM it matches on Tenant AND Purpose. Writing the answer here lets the shim
REM return it non-interactively, so an unattended build never stops on a dialog.
REM Format is exactly what the vendor dialog returns: {Tenant}_{Purpose},
REM e.g. GCCH_PERS, GCCH_SH, RoW_LOAN.
REM Only the CHOICE is pre-seeded. Token injection still happens at first logon
REM because it fetches an encrypted token table from mcl.dwcdn.geaerospace.com,
REM which is unreachable from this isolated imaging LAN.
if not "%PCCONFIG%"=="" (
echo %PCCONFIG%> %OSDRIVE%\Enrollment\pc-config.txt
echo Pre-seeded PC config %PCCONFIG% for the enrollment orchestrator.
echo [%TIME%] Wrote pc-config.txt=%PCCONFIG% >> "%STAGELOG%"
)
REM --- Copy site config (drives site-specific values in all setup scripts) ---
if exist "Y:\config\site-config.json" (
robocopy "Y:\config" "W:\Enrollment" "site-config.json" /R:1 /W:1 /NFL /NDL /NJH /NJS
echo Copied site-config.json.
echo [%TIME%] Copied site-config.json >> "%STAGELOG%"
robocopy "Y:\config" "%OSDRIVE%\Enrollment" "site-config.json" /R:1 /W:1 /NFL /NDL /NJH /NJS
if errorlevel 8 (
echo WARNING: site-config.json robocopy FAILED - not staged.
echo [%TIME%] FAILED: site-config.json robocopy >> "%STAGELOG%"
) else (
echo Copied site-config.json.
echo [%TIME%] Copied site-config.json >> "%STAGELOG%"
)
) else (
echo WARNING: site-config.json not found on enrollment share.
echo [%TIME%] WARNING: site-config.json not found >> "%STAGELOG%"
@@ -381,7 +581,7 @@ if exist "Y:\config\site-config.json" (
REM --- Copy PPKG if selected (renames from SOURCE to BPRT-tagged filename) ---
if "%PPKG%"=="" goto copy_pctype
robocopy "Y:\ppkgs" "W:\Enrollment" "%SOURCE_PPKG%" /R:1 /W:1 /NFL /NDL /NJH /NJS
robocopy "Y:\ppkgs" "%OSDRIVE%\Enrollment" "%SOURCE_PPKG%" /R:1 /W:1 /NFL /NDL /NJH /NJS
REM robocopy exit codes: 0-7 = success, 8+ = failure (inverse of copy's 0/1).
if errorlevel 8 (
echo WARNING: Failed to copy enrollment package.
@@ -389,106 +589,126 @@ if errorlevel 8 (
goto copy_pctype
)
REM robocopy keeps the source name; rename to the BPRT-tagged target name.
if not "%SOURCE_PPKG%"=="%PPKG%" ren "W:\Enrollment\%SOURCE_PPKG%" "%PPKG%"
if not "%SOURCE_PPKG%"=="%PPKG%" ren "%OSDRIVE%\Enrollment\%SOURCE_PPKG%" "%PPKG%"
echo [%TIME%] Copied PPKG %SOURCE_PPKG% as %PPKG% >> "%STAGELOG%"
robocopy "Y:\scripts" "W:\Enrollment" "run-enrollment.ps1" "wait-for-internet.ps1" "migrate-to-wifi.ps1" /R:1 /W:1 /NFL /NDL /NJH /NJS
robocopy "Y:\scripts" "%OSDRIVE%\Enrollment" "run-enrollment.ps1" "wait-for-internet.ps1" "migrate-to-wifi.ps1" /R:1 /W:1 /NFL /NDL /NJH /NJS
echo [%TIME%] Copied enrollment scripts >> "%STAGELOG%"
REM --- Create enroll.cmd at drive root as manual fallback ---
> W:\enroll.cmd (
> %OSDRIVE%\enroll.cmd (
echo @echo off
echo echo Waiting for network...
echo :waitnet
echo ping -n 2 8.8.8.8 ^>NUL 2^>^&1
echo if errorlevel 1 goto waitnet
echo echo Network connected. Running enrollment...
echo powershell.exe -ExecutionPolicy Bypass -File "C:\run-enrollment.ps1"
echo powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\run-enrollment.ps1"
)
echo Manual fallback created at W:\enroll.cmd
echo Manual fallback created at %OSDRIVE%\enroll.cmd
:copy_pctype
REM --- Copy shopfloor PC type setup scripts ---
if "%PCTYPE%"=="" goto cleanup_enroll
echo %PCTYPE%> W:\Enrollment\pc-type.txt
echo %PCTYPE%> %OSDRIVE%\Enrollment\pc-type.txt
REM 2026-05-04 rename reorg: pc-subtype.txt no longer written.
REM display-type.txt IS still written for gea-shopfloor-display because
REM Install-KioskApp.cmd reads it to pick Lobby vs Dashboard installer
REM and Get-PCProfile.ps1 reads it to build the Display-{type} profile key.
if not "%DISPLAYTYPE%"=="" echo %DISPLAYTYPE%> W:\Enrollment\display-type.txt
if not "%MACHINENUM%"=="" echo %MACHINENUM%> W:\Enrollment\machine-number.txt
if not "%DISPLAYTYPE%"=="" echo %DISPLAYTYPE%> %OSDRIVE%\Enrollment\display-type.txt
if not "%MACHINENUM%"=="" echo %MACHINENUM%> %OSDRIVE%\Enrollment\machine-number.txt
REM Keyence model goes to BOTH keyence-model.txt (read by 09-Setup-Keyence) AND
REM pc-subtype.txt (read by GE-Enforce for per-model dispatch via existing
REM PCSubType wiring: looks for gea-shopfloor-keyence-<model>\manifest.json on share).
if not "%KEYENCEMODEL%"=="" (
echo %KEYENCEMODEL%> W:\Enrollment\keyence-model.txt
echo %KEYENCEMODEL%> W:\Enrollment\pc-subtype.txt
echo %KEYENCEMODEL%> %OSDRIVE%\Enrollment\keyence-model.txt
echo %KEYENCEMODEL%> %OSDRIVE%\Enrollment\pc-subtype.txt
)
REM CMM bay ID + resolved config. CMMID comes from the bay picker (e.g. CMM4).
REM Written to machine-number.txt (same field collections/waxtrace use) so
REM TargetMachineNumbers on the SFLD share manifest gates per-bay entries.
REM resolve-cmm-bay-config.ps1 reads the CSV and writes version.txt + doda.txt
REM to W:\Enrollment\cmm\ for 09-Setup-CMM.ps1 to consume.
REM to %OSDRIVE%\Enrollment\cmm\ for 09-Setup-CMM.ps1 to consume.
if "%CMMID%"=="" goto cmm_id_done
echo %CMMID%> W:\Enrollment\machine-number.txt
mkdir W:\Enrollment\cmm 2>NUL
echo %CMMID%> %OSDRIVE%\Enrollment\machine-number.txt
mkdir %OSDRIVE%\Enrollment\cmm 2>NUL
if exist "Y:\installers-post\cmm\resolve-cmm-bay-config.ps1" (
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "Y:\installers-post\cmm\resolve-cmm-bay-config.ps1" -ConfigPath "Y:\installers-post\cmm\cmm-bay-config.csv" -CmmId "%CMMID%" -OutDir "W:\Enrollment\cmm"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "Y:\installers-post\cmm\resolve-cmm-bay-config.ps1" -ConfigPath "Y:\installers-post\cmm\cmm-bay-config.csv" -CmmId "%CMMID%" -OutDir "%OSDRIVE%\Enrollment\cmm"
)
REM Read doda flag to set pc-subtype.txt for GE-Enforce manifest gating.
REM goto-flow avoids CMD variable scoping issues inside parens blocks.
if not exist W:\Enrollment\cmm\doda.txt goto cmm_id_done
set /p CMMDODA=<W:\Enrollment\cmm\doda.txt
if /i "%CMMDODA%"=="yes" echo doda> W:\Enrollment\pc-subtype.txt
if not exist %OSDRIVE%\Enrollment\cmm\doda.txt goto cmm_id_done
set /p CMMDODA=<%OSDRIVE%\Enrollment\cmm\doda.txt
if /i "%CMMDODA%"=="yes" echo doda> %OSDRIVE%\Enrollment\pc-subtype.txt
:cmm_id_done
robocopy "Y:\shopfloor-setup" "W:\Enrollment" "Run-ShopfloorSetup.ps1" /R:1 /W:1 /NFL /NDL /NJH /NJS
robocopy "Y:\shopfloor-setup" "%OSDRIVE%\Enrollment" "Run-ShopfloorSetup.ps1" /R:1 /W:1 /NFL /NDL /NJH /NJS
REM Post-boot bulk re-fetch. WinPE staging below is best-effort (it can fail if
REM the Y: mount went idle-dead during the WIM apply); Fetch-StagingPayload.ps1
REM re-pulls the shopfloor-setup tree + preinstall bundle at first logon on a
REM FRESH mount. The unattend FirstLogonCommands runs it before the PowerShell 7
REM MSI + Run-ShopfloorSetup. Stage the script + its source coords here.
robocopy "Y:\shopfloor-setup" "W:\Enrollment" "Fetch-StagingPayload.ps1" /R:1 /W:1 /NFL /NDL /NJH /NJS
robocopy "Y:\shopfloor-setup" "%OSDRIVE%\Enrollment" "Fetch-StagingPayload.ps1" /R:1 /W:1 /NFL /NDL /NJH /NJS
REM Stage the staging self-heal directly too, so the unattend Order 5 heal step
REM runs even if Fetch-StagingPayload itself did not land. Small, reliable copy.
robocopy "Y:\shopfloor-setup" "W:\Enrollment" "Verify-And-Heal-Staging.ps1" /R:1 /W:1 /NFL /NDL /NJH /NJS
> W:\Enrollment\fetch-source.txt (
echo \\172.16.9.1\enrollment
echo pxe-upload
echo pxe
robocopy "Y:\shopfloor-setup" "%OSDRIVE%\Enrollment" "Verify-And-Heal-Staging.ps1" /R:1 /W:1 /NFL /NDL /NJH /NJS
> %OSDRIVE%\Enrollment\fetch-source.txt (
echo \\%PXESERVER%\enrollment
echo %SHAREUSER%
echo %SHAREPASS%
)
REM --- Always copy Shopfloor baseline scripts ---
mkdir W:\Enrollment\shopfloor-setup 2>NUL
robocopy "Y:\shopfloor-setup" "W:\Enrollment\shopfloor-setup" "backup_lockdown.bat" /R:1 /W:1 /NFL /NDL /NJH /NJS
mkdir %OSDRIVE%\Enrollment\shopfloor-setup 2>NUL
robocopy "Y:\shopfloor-setup" "%OSDRIVE%\Enrollment\shopfloor-setup" "backup_lockdown.bat" /R:1 /W:1 /NFL /NDL /NJH /NJS
if exist "Y:\shopfloor-setup\Shopfloor" (
mkdir W:\Enrollment\shopfloor-setup\Shopfloor 2>NUL
robocopy "Y:\shopfloor-setup\Shopfloor" "W:\Enrollment\shopfloor-setup\Shopfloor" /E /MT:16 /R:1 /W:1 /NFL /NDL /LOG+:"%STAGELOG%"
if errorlevel 8 echo WARNING: shopfloor-setup\Shopfloor robocopy exit %ERRORLEVEL%
echo Copied Shopfloor baseline setup files.
echo [%TIME%] Copied Shopfloor baseline >> "%STAGELOG%"
mkdir %OSDRIVE%\Enrollment\shopfloor-setup\Shopfloor 2>NUL
robocopy "Y:\shopfloor-setup\Shopfloor" "%OSDRIVE%\Enrollment\shopfloor-setup\Shopfloor" /E /MT:16 /R:1 /W:1 /NFL /NDL /LOG+:"%STAGELOG%"
REM Report what actually happened. These echoes used to run unconditionally
REM and printed "Copied ..." for six copies that moved zero bytes.
REM %ERRORLEVEL% is not printed: inside a parenthesised block it expands at
REM PARSE time, so it shows a stale value from before the robocopy ran.
if errorlevel 8 (
echo WARNING: shopfloor-setup\Shopfloor robocopy FAILED - nothing copied.
echo [%TIME%] FAILED: Shopfloor baseline robocopy >> "%STAGELOG%"
) else (
echo Copied Shopfloor baseline setup files.
echo [%TIME%] Copied Shopfloor baseline >> "%STAGELOG%"
)
)
REM --- Always copy common/ (cross-PC-type GE-Enforce dispatcher + lib live here post-v2) ---
if exist "Y:\shopfloor-setup\common" (
mkdir W:\Enrollment\shopfloor-setup\common 2>NUL
robocopy "Y:\shopfloor-setup\common" "W:\Enrollment\shopfloor-setup\common" /E /MT:16 /R:1 /W:1 /NFL /NDL /LOG+:"%STAGELOG%"
if errorlevel 8 echo WARNING: shopfloor-setup\common robocopy exit %ERRORLEVEL%
echo Copied common setup files.
echo [%TIME%] Copied common >> "%STAGELOG%"
mkdir %OSDRIVE%\Enrollment\shopfloor-setup\common 2>NUL
robocopy "Y:\shopfloor-setup\common" "%OSDRIVE%\Enrollment\shopfloor-setup\common" /E /MT:16 /R:1 /W:1 /NFL /NDL /LOG+:"%STAGELOG%"
if errorlevel 8 (
echo WARNING: shopfloor-setup\common robocopy FAILED - nothing copied.
echo [%TIME%] FAILED: common robocopy >> "%STAGELOG%"
) else (
echo Copied common setup files.
echo [%TIME%] Copied common >> "%STAGELOG%"
)
)
REM --- Copy _ntlars-backups (147 per-bay .reg files restored by gea-shopfloor-{collections,nocollections}\03-RestoreEDncConfig.ps1) ---
REM Same root level as common/, referenced by 03-RestoreEDncConfig.ps1 via Join-Path $PSScriptRoot '..\_ntlars-backups'.
if exist "Y:\shopfloor-setup\_ntlars-backups" (
mkdir W:\Enrollment\shopfloor-setup\_ntlars-backups 2>NUL
robocopy "Y:\shopfloor-setup\_ntlars-backups" "W:\Enrollment\shopfloor-setup\_ntlars-backups" /E /MT:16 /R:1 /W:1 /NFL /NDL /LOG+:"%STAGELOG%"
if errorlevel 8 echo WARNING: _ntlars-backups robocopy exit %ERRORLEVEL%
echo Copied _ntlars-backups.
echo [%TIME%] Copied _ntlars-backups >> "%STAGELOG%"
mkdir %OSDRIVE%\Enrollment\shopfloor-setup\_ntlars-backups 2>NUL
robocopy "Y:\shopfloor-setup\_ntlars-backups" "%OSDRIVE%\Enrollment\shopfloor-setup\_ntlars-backups" /E /MT:16 /R:1 /W:1 /NFL /NDL /LOG+:"%STAGELOG%"
if errorlevel 8 (
echo WARNING: _ntlars-backups robocopy FAILED - nothing copied.
echo [%TIME%] FAILED: _ntlars-backups robocopy >> "%STAGELOG%"
) else (
echo Copied _ntlars-backups.
echo [%TIME%] Copied _ntlars-backups >> "%STAGELOG%"
)
)
REM --- Copy type-specific scripts on top of baseline ---
if exist "Y:\shopfloor-setup\%PCTYPE%" (
mkdir "W:\Enrollment\shopfloor-setup\%PCTYPE%" 2>NUL
robocopy "Y:\shopfloor-setup\%PCTYPE%" "W:\Enrollment\shopfloor-setup\%PCTYPE%" /E /MT:16 /R:1 /W:1 /NFL /NDL /LOG+:"%STAGELOG%"
if errorlevel 8 echo WARNING: shopfloor-setup\%PCTYPE% robocopy exit %ERRORLEVEL%
echo Copied %PCTYPE% setup files.
echo [%TIME%] Copied %PCTYPE% type-specific >> "%STAGELOG%"
mkdir "%OSDRIVE%\Enrollment\shopfloor-setup\%PCTYPE%" 2>NUL
robocopy "Y:\shopfloor-setup\%PCTYPE%" "%OSDRIVE%\Enrollment\shopfloor-setup\%PCTYPE%" /E /MT:16 /R:1 /W:1 /NFL /NDL /LOG+:"%STAGELOG%"
if errorlevel 8 (
echo WARNING: shopfloor-setup\%PCTYPE% robocopy FAILED - nothing copied.
echo [%TIME%] FAILED: %PCTYPE% type-specific robocopy >> "%STAGELOG%"
) else (
echo Copied %PCTYPE% setup files.
echo [%TIME%] Copied %PCTYPE% type-specific >> "%STAGELOG%"
)
) else (
echo WARNING: No setup files found for PC type %PCTYPE%.
echo [%TIME%] WARNING: No setup files for %PCTYPE% >> "%STAGELOG%"
@@ -496,21 +716,27 @@ if exist "Y:\shopfloor-setup\%PCTYPE%" (
REM --- Stage preinstall bundle (apps installed locally to save Azure bandwidth) ---
if exist "Y:\pre-install\preinstall.json" (
mkdir W:\PreInstall 2>NUL
mkdir W:\PreInstall\installers 2>NUL
robocopy "Y:\pre-install" "W:\PreInstall" "preinstall.json" /R:1 /W:1 /NFL /NDL /NJH /NJS
mkdir %OSDRIVE%\PreInstall 2>NUL
mkdir %OSDRIVE%\PreInstall\installers 2>NUL
robocopy "Y:\pre-install" "%OSDRIVE%\PreInstall" "preinstall.json" /R:1 /W:1 /NFL /NDL /NJH /NJS
if exist "Y:\pre-install\installers" (
robocopy "Y:\pre-install\installers" "W:\PreInstall\installers" /E /MT:16 /R:1 /W:1 /NFL /NDL /LOG+:"%STAGELOG%"
if errorlevel 8 echo WARNING: pre-install\installers robocopy exit %ERRORLEVEL%
echo Staged preinstall bundle to W:\PreInstall.
robocopy "Y:\pre-install\installers" "%OSDRIVE%\PreInstall\installers" /E /MT:16 /R:1 /W:1 /NFL /NDL /LOG+:"%STAGELOG%"
if errorlevel 8 (
echo WARNING: pre-install\installers robocopy FAILED - nothing staged.
) else (
echo Staged preinstall bundle to %OSDRIVE%\PreInstall.
)
echo [%TIME%] Staged preinstall bundle >> "%STAGELOG%"
) else (
echo WARNING: Y:\pre-install\installers not found - preinstall.json staged without installers.
)
if exist "Y:\pre-install\udc-backups" (
robocopy "Y:\pre-install\udc-backups" "W:\PreInstall\udc-backups" /E /MT:16 /R:1 /W:1 /NFL /NDL /LOG+:"%STAGELOG%"
if errorlevel 8 echo WARNING: pre-install\udc-backups robocopy exit %ERRORLEVEL%
echo Staged UDC settings backups to W:\PreInstall\udc-backups.
robocopy "Y:\pre-install\udc-backups" "%OSDRIVE%\PreInstall\udc-backups" /E /MT:16 /R:1 /W:1 /NFL /NDL /LOG+:"%STAGELOG%"
if errorlevel 8 (
echo WARNING: pre-install\udc-backups robocopy FAILED - nothing staged.
) else (
echo Staged UDC settings backups to %OSDRIVE%\PreInstall\udc-backups.
)
echo [%TIME%] Staged UDC backups >> "%STAGELOG%"
)
) else (
@@ -526,24 +752,32 @@ REM bootstrap exists to get the first-install through. Post-imaging, the
REM unified GE-Enforce dispatcher takes over from the share for ongoing updates.
if /i not "%PCTYPE%"=="gea-shopfloor-cmm" goto skip_cmm_stage
if exist "Y:\installers-post\cmm\cmm-manifest.json" (
mkdir W:\CMM-Install 2>NUL
mkdir %OSDRIVE%\CMM-Install 2>NUL
REM /XD the backups tree: it holds EVERY bay's settings backup (some 240 MB
REM each). Copying all of it to every imaged CMM would waste GBs. The bulk
REM copy excludes it; the selected bay's backup is staged separately below.
robocopy "Y:\installers-post\cmm" "W:\CMM-Install" /E /XD "Y:\installers-post\cmm\backups" /MT:16 /R:1 /W:1 /NFL /NDL /LOG+:"%STAGELOG%"
if errorlevel 8 echo WARNING: cmm robocopy exit %ERRORLEVEL%
echo Staged CMM bootstrap to W:\CMM-Install.
robocopy "Y:\installers-post\cmm" "%OSDRIVE%\CMM-Install" /E /XD "Y:\installers-post\cmm\backups" /MT:16 /R:1 /W:1 /NFL /NDL /LOG+:"%STAGELOG%"
if errorlevel 8 (
echo WARNING: cmm robocopy FAILED - CMM bootstrap NOT staged.
) else (
echo Staged CMM bootstrap to %OSDRIVE%\CMM-Install.
)
echo [%TIME%] Staged CMM bootstrap >> "%STAGELOG%"
) else (
echo WARNING: Y:\cmm-installers not found - CMM PC cannot install Hexagon apps at imaging time.
)
REM Stage ONLY the selected bay's settings backup. 09-Setup-CMM's Restore-CMM
REM reads W:\CMM-Install\backups\%CMMID%\ to restore PC-DMIS + goCMM settings.
REM reads %OSDRIVE%\CMM-Install\backups\%CMMID%\ to restore PC-DMIS + goCMM settings.
if not defined CMMID goto skip_cmm_stage
if exist "Y:\installers-post\cmm\backups\%CMMID%" (
robocopy "Y:\installers-post\cmm\backups\%CMMID%" "W:\CMM-Install\backups\%CMMID%" /E /MT:16 /R:1 /W:1 /NFL /NDL /LOG+:"%STAGELOG%"
echo Staged %CMMID% settings backup for restore.
echo [%TIME%] Staged %CMMID% backup >> "%STAGELOG%"
robocopy "Y:\installers-post\cmm\backups\%CMMID%" "%OSDRIVE%\CMM-Install\backups\%CMMID%" /E /MT:16 /R:1 /W:1 /NFL /NDL /LOG+:"%STAGELOG%"
if errorlevel 8 (
echo WARNING: %CMMID% backup robocopy FAILED - settings will NOT restore.
echo [%TIME%] FAILED: %CMMID% backup robocopy >> "%STAGELOG%"
) else (
echo Staged %CMMID% settings backup for restore.
echo [%TIME%] Staged %CMMID% backup >> "%STAGELOG%"
)
)
:skip_cmm_stage
@@ -558,11 +792,14 @@ REM 09-Setup-Keyence.ps1 reads C:\Enrollment\keyence-model.txt to know which.
if /i not "%PCTYPE%"=="gea-shopfloor-keyence" goto skip_keyence_stage
if "%KEYENCEMODEL%"=="" goto skip_keyence_stage
if exist "Y:\installers-post\keyence\%KEYENCEMODEL%\manifest.json" (
mkdir W:\KeyenceInstall 2>NUL
mkdir W:\KeyenceInstall\%KEYENCEMODEL% 2>NUL
robocopy "Y:\installers-post\keyence\%KEYENCEMODEL%" "W:\KeyenceInstall\%KEYENCEMODEL%" /E /MT:16 /R:1 /W:1 /NFL /NDL /LOG+:"%STAGELOG%"
if errorlevel 8 echo WARNING: keyence\%KEYENCEMODEL% robocopy exit %ERRORLEVEL%
echo Staged Keyence %KEYENCEMODEL% bootstrap to W:\KeyenceInstall\%KEYENCEMODEL%\.
mkdir %OSDRIVE%\KeyenceInstall 2>NUL
mkdir %OSDRIVE%\KeyenceInstall\%KEYENCEMODEL% 2>NUL
robocopy "Y:\installers-post\keyence\%KEYENCEMODEL%" "%OSDRIVE%\KeyenceInstall\%KEYENCEMODEL%" /E /MT:16 /R:1 /W:1 /NFL /NDL /LOG+:"%STAGELOG%"
if errorlevel 8 (
echo WARNING: keyence\%KEYENCEMODEL% robocopy FAILED - nothing staged.
) else (
echo Staged Keyence %KEYENCEMODEL% bootstrap to %OSDRIVE%\KeyenceInstall\%KEYENCEMODEL%\.
)
echo [%TIME%] Staged Keyence %KEYENCEMODEL% >> "%STAGELOG%"
) else (
echo WARNING: Y:\installers-post\keyence\%KEYENCEMODEL% not found - Keyence %KEYENCEMODEL% cannot install at imaging time.
@@ -571,44 +808,46 @@ if exist "Y:\installers-post\keyence\%KEYENCEMODEL%\manifest.json" (
REM --- Stage WaxTrace bootstrap bundle (wax/trace gea-shopfloor-waxtrace only) ---
REM Three-step process:
REM 1. robocopy installers-post\waxtrace -> W:\WaxTrace-Install (everything
REM 1. robocopy installers-post\waxtrace -> %OSDRIVE%\WaxTrace-Install (everything
REM EXCEPT the formtracepak\ subdir, via /XD - that subdir carries all
REM 7 vendor ISOs and we only need one of them)
REM 2. resolve-bay-config.ps1: reads bay-config.csv + asset_tag, writes
REM W:\Enrollment\waxtrace\{version,model,userid}.txt for 09-Setup to read
REM %OSDRIVE%\Enrollment\waxtrace\{version,model,userid}.txt for 09-Setup to read
REM 3. robocopy ONLY the bay's matching FORMTRACEPAK-V<ver>.iso from
REM Y:\installers-post\waxtrace\formtracepak\ onto the target disk
REM (so 09-Setup-WaxAndTrace.ps1 mounts the right version per bay)
if /i not "%PCTYPE%"=="gea-shopfloor-waxtrace" goto skip_waxtrace_stage
if not exist "Y:\installers-post\waxtrace\waxtrace-manifest.json" goto skip_waxtrace_missing
mkdir W:\WaxTrace-Install 2>NUL
mkdir %OSDRIVE%\WaxTrace-Install 2>NUL
REM robocopy /XD excludes the formtracepak\ dir up front so we don't waste
REM disk + time copying 12+ GB of vendor ISOs only to delete them. The
REM matched FTPak ISO gets cherry-picked below into the same target dir.
REM /NFL/NDL keep the listing manageable; output left visible (no >NUL) so
REM any failure is debuggable from the install log. robocopy exit codes
REM 0-7 are "OK" (per Microsoft); 8+ is real failure.
robocopy "Y:\installers-post\waxtrace" "W:\WaxTrace-Install" /E /XD formtracepak /MT:16 /R:1 /W:1 /NFL /NDL
if errorlevel 8 echo WARNING: robocopy exit %ERRORLEVEL% - some files may not have copied.
mkdir W:\WaxTrace-Install\formtracepak 2>NUL
echo Staged WaxTrace bootstrap minus formtracepak\ to W:\WaxTrace-Install.
REM Resolve bay-config: writes W:\Enrollment\waxtrace\{version,model,userid}.txt
mkdir W:\Enrollment\waxtrace 2>NUL
robocopy "Y:\installers-post\waxtrace" "%OSDRIVE%\WaxTrace-Install" /E /XD formtracepak /MT:16 /R:1 /W:1 /NFL /NDL
REM Both tests read the robocopy result, so they must sit before any command
REM that sets its own errorlevel - mkdir does, which is why it moved below.
if errorlevel 8 echo WARNING: WaxTrace bootstrap robocopy FAILED - nothing staged.
if not errorlevel 8 echo Staged WaxTrace bootstrap minus formtracepak\ to %OSDRIVE%\WaxTrace-Install.
mkdir %OSDRIVE%\WaxTrace-Install\formtracepak 2>NUL
REM Resolve bay-config: writes %OSDRIVE%\Enrollment\waxtrace\{version,model,userid}.txt
mkdir %OSDRIVE%\Enrollment\waxtrace 2>NUL
if not "%MACHINENUM%"=="" (
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "Y:\installers-post\waxtrace\resolve-bay-config.ps1" -Asset "%MACHINENUM%" -OutDir "W:\Enrollment\waxtrace"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "Y:\installers-post\waxtrace\resolve-bay-config.ps1" -Asset "%MACHINENUM%" -OutDir "%OSDRIVE%\Enrollment\waxtrace"
) else (
echo WARNING: no MACHINENUM set - skipping bay-config resolve. 09-Setup-WaxAndTrace will abort cleanly with no version.
)
REM Read the resolved version + robocopy ONLY that FTPak ISO (avoids dumping
REM 12 GB of ISOs on the bay disk; only the matched ~2 GB ISO lands locally).
set WTVER=
if exist W:\Enrollment\waxtrace\version.txt set /p WTVER=<W:\Enrollment\waxtrace\version.txt
if exist %OSDRIVE%\Enrollment\waxtrace\version.txt set /p WTVER=<%OSDRIVE%\Enrollment\waxtrace\version.txt
if not "%WTVER%"=="" (
if exist "Y:\installers-post\waxtrace\formtracepak\FORMTRACEPAK-V%WTVER%.iso" (
REM robocopy /J = unbuffered I/O, meaningfully faster than xcopy on
REM 2 GB FTPak ISOs over SMB. Single-file syntax: source dir, dest
REM dir, file pattern.
robocopy "Y:\installers-post\waxtrace\formtracepak" "W:\WaxTrace-Install\formtracepak" "FORMTRACEPAK-V%WTVER%.iso" /J /R:1 /W:1 /NFL /NDL
robocopy "Y:\installers-post\waxtrace\formtracepak" "%OSDRIVE%\WaxTrace-Install\formtracepak" "FORMTRACEPAK-V%WTVER%.iso" /J /R:1 /W:1 /NFL /NDL
echo Staged FormTracePak V%WTVER%.iso for %MACHINENUM%.
) else (
echo WARNING: No FORMTRACEPAK-V%WTVER%.iso on share for %MACHINENUM% - 09-Setup-WaxAndTrace will abort cleanly with the version-mismatch error.
@@ -616,17 +855,17 @@ if not "%WTVER%"=="" (
) else (
echo WARNING: bay-config did not resolve a FTPak version for %MACHINENUM%.
)
REM Per-asset backup ZIPs land at W:\WaxTrace-Install\backups\<asset>.zip
REM Per-asset backup ZIPs land at %OSDRIVE%\WaxTrace-Install\backups\<asset>.zip
REM as part of the robocopy bundle above (backups\ is not in the /XD list).
REM 09-Setup-WaxAndTrace.ps1 Step 3b reads C:\WaxTrace-Install\backups\
REM <asset>.zip post-vendor-MSI and runs Install-FormtracepakSettings.ps1
REM -RestoreData -RestoreConfig. Total ~17 MB on disk for the whole set, no
REM cherry-pick needed.
if not "%MACHINENUM%"=="" (
if exist "W:\WaxTrace-Install\backups\%MACHINENUM%.zip" (
if exist "%OSDRIVE%\WaxTrace-Install\backups\%MACHINENUM%.zip" (
echo Backup ZIP %MACHINENUM%.zip is in the bundle - 09-Setup Step 3b will restore.
) else (
echo INFO: No backup ZIP for %MACHINENUM% in W:\WaxTrace-Install\backups - 09-Setup will skip the restore step.
echo INFO: No backup ZIP for %MACHINENUM% in %OSDRIVE%\WaxTrace-Install\backups - 09-Setup will skip the restore step.
)
)
goto skip_waxtrace_stage
@@ -636,7 +875,64 @@ echo WARNING: Y:\installers-post\waxtrace not found - WaxTrace PC cannot install
:pctype_done
if defined STAGELOG echo [%TIME%] WinPE staging complete >> "%STAGELOG%"
REM --- BIOS update sub-stage push (fires AFTER W: copies complete) ---
REM --- Verify what we just staged ------------------------------------------
REM Check the payload BEFORE anything depends on it, while a tech is still
REM standing at the machine. A short copy found here costs 30 seconds; the same
REM copy found at first logon costs 20 minutes, and found never costs a rebuild.
REM Four Display bays sat at "imaging stage 2, green" for weeks because nothing
REM checked. Retry once for the small trees; the ppkg is 8 GB so report it
REM rather than blindly re-pulling it.
if "%PCTYPE%"=="" goto verify_done
set STAGEBAD=
if not exist "%OSDRIVE%\Enrollment\pc-type.txt" set STAGEBAD=%STAGEBAD% pc-type.txt
if not exist "%OSDRIVE%\Enrollment\Run-ShopfloorSetup.ps1" set STAGEBAD=%STAGEBAD% Run-ShopfloorSetup.ps1
if not exist "%OSDRIVE%\Enrollment\shopfloor-setup\common" set STAGEBAD=%STAGEBAD% shopfloor-setup\common
if not exist "%OSDRIVE%\Enrollment\shopfloor-setup\%PCTYPE%" set STAGEBAD=%STAGEBAD% shopfloor-setup\%PCTYPE%
if "%STAGEBAD%"=="" goto verify_ppkg
echo.
echo Staging verify: missing%STAGEBAD% - retrying once...
echo [%TIME%] VERIFY: missing%STAGEBAD% - retry >> "%STAGELOG%"
robocopy "Y:\shopfloor-setup" "%OSDRIVE%\Enrollment" "Run-ShopfloorSetup.ps1" /R:2 /W:2 /NFL /NDL /NJH /NJS
robocopy "Y:\shopfloor-setup\common" "%OSDRIVE%\Enrollment\shopfloor-setup\common" /E /MT:16 /R:2 /W:2 /NFL /NDL /NJH /NJS
robocopy "Y:\shopfloor-setup\%PCTYPE%" "%OSDRIVE%\Enrollment\shopfloor-setup\%PCTYPE%" /E /MT:16 /R:2 /W:2 /NFL /NDL /NJH /NJS
if not exist "%OSDRIVE%\Enrollment\pc-type.txt" echo %PCTYPE%> %OSDRIVE%\Enrollment\pc-type.txt
set STAGEBAD=
if not exist "%OSDRIVE%\Enrollment\pc-type.txt" set STAGEBAD=%STAGEBAD% pc-type.txt
if not exist "%OSDRIVE%\Enrollment\Run-ShopfloorSetup.ps1" set STAGEBAD=%STAGEBAD% Run-ShopfloorSetup.ps1
if not exist "%OSDRIVE%\Enrollment\shopfloor-setup\common" set STAGEBAD=%STAGEBAD% shopfloor-setup\common
if not exist "%OSDRIVE%\Enrollment\shopfloor-setup\%PCTYPE%" set STAGEBAD=%STAGEBAD% shopfloor-setup\%PCTYPE%
if "%STAGEBAD%"=="" (
echo Staging verify: retry fixed it.
echo [%TIME%] VERIFY: retry succeeded >> "%STAGELOG%"
goto verify_ppkg
)
echo.
echo ******************************************************************
echo STAGING INCOMPLETE after retry:%STAGEBAD%
echo This bay will image but the post-install setup will NOT run
echo properly. Fix the share and re-image. Tell the PXE admin.
echo ******************************************************************
echo.
echo [%TIME%] VERIFY FAILED after retry:%STAGEBAD% >> "%STAGELOG%"
:verify_ppkg
REM The enrollment package is the expensive one - 8 GB. Report, do not re-pull.
if "%PPKG%"=="" goto verify_done
if exist "%OSDRIVE%\Enrollment\%PPKG%" (
echo Staging verify: enrollment package present.
echo [%TIME%] VERIFY: %PPKG% present >> "%STAGELOG%"
) else (
echo.
echo WARNING: enrollment package %PPKG% is MISSING from %OSDRIVE%\Enrollment.
echo Enrollment will not run. Re-image this bay.
echo.
echo [%TIME%] VERIFY FAILED: %PPKG% missing >> "%STAGELOG%"
)
:verify_done
REM --- BIOS update sub-stage push (fires AFTER %OSDRIVE% copies complete) ---
REM check-bios.cmd drops X:\bios-fired.flag iff it actually flashed or
REM staged a firmware update. Reading the flag file is more reliable than
REM scanning BIOS_STATUS for substrings (caret escaping inside quoted SET
@@ -655,6 +951,53 @@ net use Y: /delete 2>NUL
ping -n 11 127.0.0.1 >NUL
wmic process where "name='PESetup.exe'" get name 2>NUL | find /I "PESetup" >NUL
if not errorlevel 1 goto wait_finish
REM --- Harvest the imaging logs to the share --------------------------------
REM Every diagnosis of a failed build so far has meant walking to the bay and
REM copying files off by hand. PESetup preserves its log and the generated
REM unattend, DISM writes its apply logs, and our staging log sits on the target
REM - all of it is thrown away at reboot unless collected now. Runs after
REM PESetup exits so the logs are final, which means re-mapping Y: (cleanup
REM already dropped it).
REM Best-effort throughout: a bay must never fail to reboot because a log copy
REM did not work.
set SERIAL=
for /f "skip=1 tokens=*" %%S in ('wmic bios get serialnumber 2^>NUL') do if not defined SERIAL set SERIAL=%%S
for /f "tokens=*" %%a in ("%SERIAL%") do set SERIAL=%%a
if "%SERIAL%"=="" set SERIAL=unknown-serial
net use Y: \\%PXESERVER%\enrollment /user:%SHAREUSER% %SHAREPASS% /persistent:no >NUL 2>&1
if not exist "Y:\" goto harvest_done
set LOGDEST=Y:\imaging-logs\%SERIAL%
mkdir "Y:\imaging-logs" 2>NUL
mkdir "%LOGDEST%" 2>NUL
echo Collecting imaging logs to %LOGDEST% ...
REM WinPE side: DISM apply logs, the unattend PESetup generated, BIOS flash log.
robocopy X:\ "%LOGDEST%" "*.log" "Unattend.xml" /R:1 /W:1 /NFL /NDL /NJH /NJS >NUL 2>&1
REM Target side: PESetup's own log lands in Panther, and our staging log.
if defined OSDRIVE (
robocopy "%OSDRIVE%\Windows\Panther" "%LOGDEST%" "PESetup*.log" /R:1 /W:1 /NFL /NDL /NJH /NJS >NUL 2>&1
robocopy "%OSDRIVE%\Enrollment" "%LOGDEST%" "winpe-staging.log" /R:1 /W:1 /NFL /NDL /NJH /NJS >NUL 2>&1
)
REM A breadcrumb of what this build actually was, so the logs are readable
REM without cross-referencing the webapp.
> "%LOGDEST%\build-context.txt" (
echo Serial=%SERIAL%
echo PCTYPE=%PCTYPE%
echo PPKG=%PPKG%
echo MACHINENUM=%MACHINENUM%
echo CMMID=%CMMID%
echo KEYENCEMODEL=%KEYENCEMODEL%
echo DISPLAYTYPE=%DISPLAYTYPE%
echo OSDRIVE=%OSDRIVE%
echo Media=%MEDIAPATH%
echo Date=%DATE% %TIME%
)
echo Imaging logs collected.
net use Y: /delete /y >NUL 2>&1
:harvest_done
echo.
echo Imaging complete. Rebooting in 15 seconds...
echo Press Ctrl+C to cancel.

View File

@@ -13,8 +13,8 @@
# Pushes the NEWEST backup set per cmm_id to:
# /srv/samba/enrollment/installers-post/cmm/backups/<cmm_id>/
#
# DODA bays: do NOT back them up (don't put them under cmm-bk/). 09-Setup-CMM
# also gates restore on cmm-bay-config doda=no as a second guard.
# DODA bays ARE backed up + restored like any other now (the old skip-on-doda
# policy in Restore-CMM was dropped 2026-06-17). Stage them here the same way.
#
# Usage: ./playbook/sync-cmm-backups.sh (all cmm_id folders)
# CMM_ID=CMM3 ./playbook/sync-cmm-backups.sh (just one)

31
reference/e10/README.md Normal file
View File

@@ -0,0 +1,31 @@
# SIC e10 reference snapshot
Pulled off the bench controller (a P63, firmware `E10 P63 (20140924)`) on
2026-08-27 with `scripts/e10-client.py backup`. 52 files: marking files,
scribble logos, and the controller's own config files.
Used as the reference corpus for reversing the `MARK_V5.0` marking file format
and for testing the e10 client without hardware attached.
## Deliberately missing: ConfigCtrl
`ConfigCtrl` is the controller configuration file. **It stores the programmer
and supervisor password in cleartext**, so it is not in this repo and must
never be committed. It is kept outside git at
`~/pxe-images/e10-ConfigCtrl-2026-08-27.kind1`, mode 0600, following the same
convention as the other site-sensitive artefacts (ADR-015).
`.gitignore` blocks `ConfigCtrl*` and `*.kind1` under this directory so a
future snapshot cannot reintroduce it by accident.
This has a direct consequence for the backup design: **any e10 snapshot that
includes the controller config contains a live credential.** Whatever ACL
protects the partmarker backup tree on the share has to account for that, and
the ShopDB asset panel must never render the file's contents.
## Part programs
GE part programs are NOT in this repo - they carry part numbers and marking
content. They live outside git at `~/pxe-images/e10-testing/partprograms/`,
alongside the testing README. `.gitignore` blocks the path here so one cannot
be added by accident.

View File

@@ -0,0 +1,51 @@
70cce4c34a91027ed94bbe8b756598eee8af17f2d002f3460bd90d990fb436b4 AROBAS.scribblelogo
10e2f1aa3071ca2d84a5349166ed3c660b8ff008eb944ef92cfc5274f005599e C151TEST.marking
d3221a50bcaca36382797b7f6e8f8e8faa50324e37a9e3b4505b3642a048493d C151ZATEST.marking
e091265e92eb2b6f205cf9df04eac49197e6af32c87b8c18142a56c2c7219b92 C153DTEST.marking
64e0af0dc88768918c855ac79476eafd4f847a6457c3cc887a67965ccf1dc207 C153TEST.marking
1e6e804d24110ae1e6737a02019ffa4b2fd5a439dbf63197e81761ff279a0042 C153ZA_TEST.marking
3b11990968ddefa701de6fdcd3b2b6c444628aa131b883c800b6cb23cdde63de C303TEST.marking
2e838035bea993f4b41ed3aef294492745d5d7ac09b46a40eca2b63e61f0e28b CE.scribblelogo
477790ff20bf8e19dfc61091af720be628cabc861062b3bfbae573cf7d2e9eb0 DIAMETR1.scribblelogo
af6bb7fc933a0204ae2d13428abf1e690b84875c77a6b135be702eff33a69cb5 DOLLAR.scribblelogo
414e0eef39661ce6974c332ff87fd1e33e32dca16d4c3fcb180844d3f712fe27 EURO.scribblelogo
e0084b66cb4a03e2aba6cc7342eead6a91610ad915d66f9ae2c734f99b772ac7 File-Select.kind1
2c04cfccfe998cec4f2174b5f4afb38f674606b1fea4de0cf27762e9d1811de9 FLECHE.scribblelogo
e54393d2f36be9c038b435cd378bef7841bdebecd2f5377ae061179250a60b46 FolderDef.kind1
0979584b27575fd47098af4c247ce3753ca230682bf81e1b4981c3473ea31da2 I111DTEST.marking
466c52d43773f34953db31812f041ed5607f6a439018382e5a4c632378609e1d I111STEST.marking
faf4e6c890038fa694efb0c5e71b90882ef963f77b88eb78cbca6b9e3913c9ed I113DTEST.marking
5c023699db1bc23f1486fbe1bd9dbf47020be0745eabe3d6a0f39f01af0d24bb I113STEST.marking
82c4eb3df4fa3c999ac852dd76ffb115e6e183e1266efbeb5e59711ad054efe3 I141_ATEST.marking
ca605734925a2369ad27370a628afc8d78197dd2916059413066282278aa064b I141TEST.marking
f60c08c64e67ee15da84e6af251f103b1851d30aa64b72ae685bd0a7d635091b I52TEST.marking
cb5c02fad724be06f5ca7ec2e07684ca1c6e876dbe9fc93d8c5b2a4d027b76e5 I61HATEST.marking
ab7e80458d29f40643c5f29b028998ee461449c2fb60654b8eb7d1db0dac66d9 I61STEST.marking
a1a649f60076a12af8cebf7dddd21a6a50b4679e2263a0959880fe634ee19395 I81-ATEST.marking
276157a44ad7b0d5b658c0207b66344f1b6c7747d600435150b4f08cb3c2319d I81TEST.marking
a3958d8271b395431cbbd9b70fbf992bec29d4fdc50aed3a89352b94040ce3ce MIRE.scribblelogo
51f32036eafdc34ee296c7af0f99497961a85c88e9490698782f80d0f0bbd5a4 NEW.marking
517b1254b6c7636d25cee78c41d4164d523069db6477119c7b3934c9953a3092 NF.scribblelogo
e9c628286a4a5e8dd21e40524081dc16f56e7d252eff4a0bc60885153d677836 P122_40TEST.marking
87899b8fcd02a53ce2b7cece87b22339a326e0d0e3a4471d405413623bf9f8ce P122TEST.marking
fa1dc4effb6af039c47665597183030bf77939e68f0e6ee7c21dce6b258ed249 P62TEST.marking
ed5c962db3376f43ec5a0b0bdeb3d42df0d08671d86d199995dbd065ef27cd72 PHONE.scribblelogo
7a8749129203e4729a3f40b63d98963ce658c5c523d701a20817cc243375a598 POWER.scribblelogo
b280f55cefb8467f2e5bc72bf573ac1b12e2c37aaed7b65cc01994cfd596a9bb RECYCLAG.scribblelogo
49bf169537af2b2d830331dd8d263e684bc6a831cfcaf2dadd7d3005c80507f2 ROUND.scribblelogo
f7d6a767b43d8d990937edc87c64fe16e3df48a2a62bdd182d8ee0570dbb6b65 RROUND.scribblelogo
b63008e8a99d098774db34e5eb759ee7ce2cb6935061aad4f4639ad1d3101214 RU.marking
befeb2d21947be021bfa8bcbacfa1a1a9410cf07a1502425d16dae0efedad3b3 SAVE_ACC_1.kind7
d9cb28a5ca4742d2a99b302cada595171d9aba83892973f6c09a00f5a04b9be8 SENSINTE1.scribblelogo
90d36b9d0167d6343fb71fbc65750d4f162a31d394bf6fd15700481608b66b43 SIC.scribblelogo
5223b4860a45e531c04ea68038e5654504ff07968cb31ed409879dafc3dcd3d6 SQUARE.scribblelogo
a3ffe9466ef36622242e9b878cf3a5059428db3580fe623f99a62b0f0bff6252 STAR.scribblelogo
7fe8657ba86d3d048d5b1ff529bde7dcd138946a8aed54284d51f959d8d7815c TENSION.scribblelogo
c0061f304587e65597a6a685d009f5e36dae49aa662f5cbc2ed8bc8d5c13c124 TERRE.scribblelogo
6dacf2f4ce950e9a29bdc525b43966fe05e00166d668a6108e48a89a1e4463c4 TEST_DB1.marking
e510e74205dc57d106c0ab5484200a7a7ba8616c3d9580bc45500be00b04dc6f TEST_DB2.marking
3aefdb0845861635ba9c9bbe73ebb020736786ff688267a1e048f281481d20a5 TEST_DB3.marking
95a4056868e686d14766d74158ac781b91903df158fde5691f003d3aca0324be TEST.marking
de6574fa8579b4fe1d2d9f627f9f203354eafd2a2facbf183c1656d2f2d053d2 TESTP120.marking
f6a519fb92aff6efdd59caaded3728f38b878b83fa51ac37ca081b452a8d960c TEST_RU.marking
73aa19ae9f2feef8b85dc5de2659b097b8580470be69c2afd758fe30b6def398 TRIANGLE.scribblelogo

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More