Compare commits
90 Commits
renumber-1
...
fix/sfld-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ec097069c | ||
|
|
ca9b0de89c | ||
|
|
01cf99da01 | ||
|
|
47f5d4603a | ||
|
|
5e249e8ad0 | ||
|
|
eb4d067f12 | ||
|
|
f1755751bb | ||
|
|
8ba86ba560 | ||
|
|
f99ece1366 | ||
|
|
ba4df185cd | ||
|
|
bd131f6da9 | ||
|
|
18168b54f8 | ||
|
|
b228a69364 | ||
|
|
abbdc9873c | ||
|
|
280a3b3ba4 | ||
|
|
e40a294193 | ||
|
|
12c6c771dc | ||
|
|
d479e6823c | ||
|
|
ad075c255c | ||
|
|
6e8b8a83a7 | ||
|
|
6f86c81a44 | ||
|
|
2415e6cfd6 | ||
|
|
f3df6c7175 | ||
|
|
26d14b0684 | ||
|
|
cc20501e2e | ||
|
|
0d9c4fdac4 | ||
|
|
54cbe6b5d6 | ||
|
|
b075939c38 | ||
|
|
6d5fee786c | ||
|
|
0e1b80b903 | ||
|
|
8907a86c42 | ||
|
|
a7cb7164d9 | ||
|
|
28efde7d76 | ||
|
|
d5cf255443 | ||
|
|
7ed30f9b85 | ||
|
|
c4aeaaaa17 | ||
|
|
54176e591f | ||
|
|
d185e2b810 | ||
|
|
68f2ce29e4 | ||
|
|
f442ff2f65 | ||
|
|
a0aceb4680 | ||
|
|
bb08392b84 | ||
|
|
9d51c0b987 | ||
|
|
68df59e117 | ||
|
|
d2200e8522 | ||
|
|
8c21282024 | ||
|
|
36be60e9ae | ||
|
|
b96ff7e970 | ||
|
|
d04865da0f | ||
|
|
2b6189f739 | ||
|
|
385acfefa7 | ||
|
|
d5f7abc19f | ||
|
|
049dd7bde6 | ||
|
|
c7b17696d0 | ||
|
|
dfa026b4ee | ||
|
|
936902dc4d | ||
|
|
27e76467a8 | ||
|
|
66c24b5d59 | ||
|
|
1a175bc4fe | ||
|
|
01f524e8b3 | ||
|
|
3880151e71 | ||
|
|
5f97e81dec | ||
|
|
626561a1fa | ||
|
|
f1d9bdf478 | ||
|
|
cc391529bd | ||
|
|
234295931e | ||
|
|
0ffdcc79ed | ||
|
|
3b63697176 | ||
|
|
0cb6b26c27 | ||
|
|
8fbae24b4d | ||
|
|
18db077475 | ||
|
|
8df30017aa | ||
|
|
b240cfea12 | ||
|
|
e7be0f5391 | ||
|
|
0c4c2a245d | ||
|
|
fdf63ab32f | ||
|
|
64e1f7e088 | ||
|
|
287ec86c12 | ||
|
|
85e7d91b1a | ||
|
|
2461804fc8 | ||
|
|
48bc609eb5 | ||
|
|
132c57ab39 | ||
|
|
99deaa6882 | ||
|
|
948d013539 | ||
|
|
6e218f3daf | ||
|
|
b5b644a360 | ||
|
|
bc89ba1cf3 | ||
|
|
1487abdba5 | ||
|
|
7af66575d0 | ||
|
|
8e11f0824a |
8
.gitignore
vendored
8
.gitignore
vendored
@@ -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
95
docs/OWNERSHIP.md
Normal 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
278
docs/PESETUP-INTERNALS.md
Normal 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
|
||||
```
|
||||
695
docs/ZERO-TOUCH-IMAGING-PROPOSAL.md
Normal file
695
docs/ZERO-TOUCH-IMAGING-PROPOSAL.md
Normal 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
|
||||
```
|
||||
296
docs/mtconnect-followup-runbook.md
Normal file
296
docs/mtconnect-followup-runbook.md
Normal 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.
|
||||
380
playbook/FlatUnattendW10-engineer.xml
Normal file
380
playbook/FlatUnattendW10-engineer.xml
Normal 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 & net start wlansvc & 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 & reg add "HKU\TempDU\Software\Policies\Microsoft\Windows\Explorer" /v DisableSearchBoxSuggestions /t REG_DWORD /d 1 /f & 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -51,18 +51,15 @@
|
||||
language="neutral"
|
||||
versionScope="nonSxS">
|
||||
<RunSynchronous>
|
||||
<!-- EAP-PEAP MSI -->
|
||||
|
||||
<!-- 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>msiexec /i "C:\Deploy\Applications\extra\wireless\EAP-PEAP.msi" /quiet /norestart</Path>
|
||||
<Description>Install EAP-PEAP</Description>
|
||||
</RunSynchronousCommand>
|
||||
|
||||
<!-- Wi-Fi profiles -->
|
||||
<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>
|
||||
<Path>cmd /c sc config wlansvc start= auto & net start wlansvc & 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">
|
||||
@@ -257,6 +249,57 @@
|
||||
<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>
|
||||
@@ -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,24 +324,43 @@
|
||||
<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\ && install.bat"</CommandLine>
|
||||
<Description>Install Microsoft Office</Description>
|
||||
<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>
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
170
playbook/scripts/preflight.ps1
Executable 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
|
||||
@@ -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
|
||||
|
||||
84
playbook/shopfloor-setup/BPRT/Select-PCConfig.ps1
Normal file
84
playbook/shopfloor-setup/BPRT/Select-PCConfig.ps1
Normal 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
|
||||
409
playbook/shopfloor-setup/Collect-ImagingDiagnostics.ps1
Normal file
409
playbook/shopfloor-setup/Collect-ImagingDiagnostics.ps1
Normal 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 ""
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,46 +35,142 @@ 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) {
|
||||
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'
|
||||
|
||||
# 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
|
||||
}
|
||||
}
|
||||
|
||||
# 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"
|
||||
} else {
|
||||
Write-RegLog "Map-SfldShare.ps1 not found at $src - cannot register"
|
||||
|
||||
# --- 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 {
|
||||
# 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)
|
||||
|
||||
$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)
|
||||
}
|
||||
|
||||
# 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 task: $_"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 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) {
|
||||
# --- 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 {
|
||||
Unregister-ScheduledTask -TaskName $legacyTask -Confirm:$false -ErrorAction Stop
|
||||
Write-RegLog "Removed legacy scheduled task '$legacyTask'"
|
||||
$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 "Failed to remove legacy task '$legacyTask': $_"
|
||||
}
|
||||
}
|
||||
|
||||
# Register HKLM\Run entry. Runs at Explorer startup for every
|
||||
# interactive user in that user's session.
|
||||
try {
|
||||
$command = '"{0}" -NoProfile -ExecutionPolicy Bypass -File "{1}"' -f `
|
||||
"$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe", $mapScript
|
||||
|
||||
if (-not (Test-Path $runKey)) {
|
||||
New-Item -Path $runKey -Force | Out-Null
|
||||
}
|
||||
New-ItemProperty -Path $runKey -Name $runValue -Value $command -PropertyType String -Force | Out-Null
|
||||
Write-RegLog "Set $runKey\$runValue = $command"
|
||||
} catch {
|
||||
Write-RegLog "FAILED to register Run key: $_"
|
||||
exit 1
|
||||
Write-RegLog "Stamp write failed (registrar will re-run next cycle): $_"
|
||||
}
|
||||
|
||||
Write-RegLog '=== Register-MapSfldShare end ==='
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
526
playbook/shopfloor-setup/common/lib/ShopdbBackupClient.psm1
Normal file
526
playbook/shopfloor-setup/common/lib/ShopdbBackupClient.psm1
Normal 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
|
||||
562
playbook/shopfloor-setup/common/lib/TelesisMarker.psm1
Normal file
562
playbook/shopfloor-setup/common/lib/TelesisMarker.psm1
Normal 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
|
||||
@@ -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
|
||||
583
playbook/shopfloor-setup/common/scripts/Backup-PartMarker.ps1
Normal file
583
playbook/shopfloor-setup/common/scripts/Backup-PartMarker.ps1
Normal 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
|
||||
33
playbook/shopfloor-setup/common/scripts/Install-AcroReader.cmd
Executable file
33
playbook/shopfloor-setup/common/scripts/Install-AcroReader.cmd
Executable 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
|
||||
75
playbook/shopfloor-setup/common/scripts/Install-Oracle11r2.cmd
Executable file
75
playbook/shopfloor-setup/common/scripts/Install-Oracle11r2.cmd
Executable 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%
|
||||
62
playbook/shopfloor-setup/common/scripts/Migrate-PCType.ps1
Executable file
62
playbook/shopfloor-setup/common/scripts/Migrate-PCType.ps1
Executable 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
|
||||
186
playbook/shopfloor-setup/common/scripts/Repair-AppShortcuts.ps1
Normal file
186
playbook/shopfloor-setup/common/scripts/Repair-AppShortcuts.ps1
Normal 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
|
||||
68
playbook/shopfloor-setup/common/scripts/Select-KioskType.ps1
Normal file
68
playbook/shopfloor-setup/common/scripts/Select-KioskType.ps1
Normal 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
|
||||
54
playbook/shopfloor-setup/common/scripts/Set-EventSaverDisable.ps1
Executable file
54
playbook/shopfloor-setup/common/scripts/Set-EventSaverDisable.ps1
Executable 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
|
||||
29
playbook/shopfloor-setup/common/scripts/Set-EventSaverPower.ps1
Executable file
29
playbook/shopfloor-setup/common/scripts/Set-EventSaverPower.ps1
Executable 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
|
||||
123
playbook/shopfloor-setup/common/scripts/Set-EventSaverScreensaver.ps1
Executable file
123
playbook/shopfloor-setup/common/scripts/Set-EventSaverScreensaver.ps1
Executable 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
|
||||
46
playbook/shopfloor-setup/common/scripts/Set-FmsHostsEntry.ps1
Executable file
46
playbook/shopfloor-setup/common/scripts/Set-FmsHostsEntry.ps1
Executable 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
|
||||
@@ -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}: $_"
|
||||
}
|
||||
@@ -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
|
||||
15
playbook/shopfloor-setup/common/scripts/Setup-OpenText.cmd
Executable file
15
playbook/shopfloor-setup/common/scripts/Setup-OpenText.cmd
Executable 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%
|
||||
361
playbook/shopfloor-setup/common/scripts/Setup-OpenText.ps1
Executable file
361
playbook/shopfloor-setup/common/scripts/Setup-OpenText.ps1
Executable 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
|
||||
82
playbook/shopfloor-setup/common/scripts/Test-RegExport.ps1
Normal file
82
playbook/shopfloor-setup/common/scripts/Test-RegExport.ps1
Normal 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 " $_" }
|
||||
249
playbook/shopfloor-setup/common/scripts/Test-ShareAccess.ps1
Normal file
249
playbook/shopfloor-setup/common/scripts/Test-ShareAccess.ps1
Normal 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
|
||||
37
playbook/shopfloor-setup/common/scripts/ensure-vnc-firewall.ps1
Executable file
37
playbook/shopfloor-setup/common/scripts/ensure-vnc-firewall.ps1
Executable 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
|
||||
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
@@ -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",
|
||||
|
||||
@@ -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 ================"
|
||||
|
||||
@@ -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)."
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 ''
|
||||
@@ -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 = ''
|
||||
$gw = ''
|
||||
try {
|
||||
$adapter = Get-NetAdapter -InterfaceIndex $ipo.InterfaceIndex -ErrorAction Stop
|
||||
$mac = $adapter.MacAddress
|
||||
} catch {}
|
||||
$gw = ''
|
||||
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 ($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
|
||||
|
||||
@@ -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 '==============================================='
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
15
playbook/shopfloor-setup/menu.json
Normal file
15
playbook/shopfloor-setup/menu.json
Normal 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 }
|
||||
]
|
||||
}
|
||||
@@ -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: $_"
|
||||
|
||||
63
playbook/shopfloor-setup/select-shopfloor-type.ps1
Normal file
63
playbook/shopfloor-setup/select-shopfloor-type.ps1
Normal 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
|
||||
}
|
||||
@@ -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" }
|
||||
]
|
||||
|
||||
@@ -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=
|
||||
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
|
||||
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%
|
||||
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%
|
||||
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%
|
||||
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%
|
||||
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,25 +752,33 @@ 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%"
|
||||
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
|
||||
|
||||
REM --- Stage Keyence per-model bootstrap bundle (Keyence PCs only) ---
|
||||
@@ -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.
|
||||
|
||||
@@ -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
31
reference/e10/README.md
Normal 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.
|
||||
51
reference/e10/snapshot-2026-08-27.sha256
Normal file
51
reference/e10/snapshot-2026-08-27.sha256
Normal 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
|
||||
BIN
reference/e10/snapshot-2026-08-27/AROBAS.scribblelogo
Normal file
BIN
reference/e10/snapshot-2026-08-27/AROBAS.scribblelogo
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/C151TEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/C151TEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/C151ZATEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/C151ZATEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/C153DTEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/C153DTEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/C153TEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/C153TEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/C153ZA_TEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/C153ZA_TEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/C303TEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/C303TEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/CE.scribblelogo
Normal file
BIN
reference/e10/snapshot-2026-08-27/CE.scribblelogo
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/DIAMETR1.scribblelogo
Normal file
BIN
reference/e10/snapshot-2026-08-27/DIAMETR1.scribblelogo
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/DOLLAR.scribblelogo
Normal file
BIN
reference/e10/snapshot-2026-08-27/DOLLAR.scribblelogo
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/EURO.scribblelogo
Normal file
BIN
reference/e10/snapshot-2026-08-27/EURO.scribblelogo
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/FLECHE.scribblelogo
Normal file
BIN
reference/e10/snapshot-2026-08-27/FLECHE.scribblelogo
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/I111DTEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/I111DTEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/I111STEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/I111STEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/I113DTEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/I113DTEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/I113STEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/I113STEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/I141TEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/I141TEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/I141_ATEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/I141_ATEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/I52TEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/I52TEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/I61HATEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/I61HATEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/I61STEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/I61STEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/I81-ATEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/I81-ATEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/I81TEST.marking
Normal file
BIN
reference/e10/snapshot-2026-08-27/I81TEST.marking
Normal file
Binary file not shown.
BIN
reference/e10/snapshot-2026-08-27/MIRE.scribblelogo
Normal file
BIN
reference/e10/snapshot-2026-08-27/MIRE.scribblelogo
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user