One enrollment file names the device, for every bay without a registry

A bay with an NTLARS/eDNC MachineNo registry is identified by it, and a CMM by
cmmid.txt. Everything else - Keyence, Genspect, wax-trace, part markers, and
whatever a site declares next - had no stable identity at all, and the two
mechanisms that stood in for one both key off the PC: reuse looks for a prior
link from THIS PC asset, and minting builds `<PC number>-<SUFFIX>` where a PC's
number is its hostname.

Both survive a re-image. NEITHER survives a PC SWAP. A new hostname is a new PC
asset with no prior link and a predicted number that has never existed, so the
same physical device gets a second record while the first keeps its config and
its backup history under a dead PC's name. That is how 43 legacy MT-#### tools
ended up shadowed by minted twins, three records deep in places. The metrology
path learned this and gained an explicit id file; the part-marker path was
modelled on the metrology path as it stood BEFORE that fix, so it inherited the
defect - and its own docstring said so, describing minting "the same way it
already mints a CMM".

C:\Enrollment\asset-id.txt is now that identity for both, and for anything
declared later through subordinatedevice_<pctype>. It holds one line, the
device's assetnumber, and deliberately does NOT record what kind of device it
is: the pc-type already does, so a new device type needs no new file and no
client change. Resolution puts it first, ahead of everything PC-derived.

NOT machine-number.txt. machinenumber answers "which bay is this" and is what
GE-Enforce TargetMachineNumbers gates on, so naming a device there would
silently stop every bay-gated manifest entry from matching. A part marker still
files partof the operation from machinenumber; asset-id.txt changes which marker
the PC controls, not which operation that marker belongs to.

The wire field is `deviceid` rather than `assetid`, because assetid is already
this contract's RESPONSE field for a PC's integer primary key - the two would
have sat side by side meaning different things. measuringtoolid, shipped in
0.12.0, is accepted as an alias and the client still reads the older file, so a
bay staged in the last day keeps reporting; deviceid wins when both arrive.

Two guards, shared by both device families: a value that resolves to nothing
warns instead of minting a phantom, and a value that resolves to the WRONG KIND
of asset is refused with the asset named, so a machine number pasted into the
file cannot be filed under a device label the collector also owns.

Row creation is now one helper instead of a copy per path, because the named and
minted branches both need get-or-create on the extension row and the control
link.

1744 tests green, including a new file that pins the swap case both ways - with
the file one device, without it two. Five of its tests failed first time because
the fixtures built bare assets with no extension row and the type guard refused
them, which is the guard working.

VERIFIED ON WINDOWS 11 (build 26200), five cases: the new file alone; both files
present, new winning; the legacy file alone; neither, sending no field; and a
padded value with a trailing line.
This commit is contained in:
cproudlock
2026-08-20 10:41:00 -04:00
parent 21afa0b56e
commit 68b86d459e
4 changed files with 439 additions and 94 deletions

View File

@@ -201,7 +201,8 @@ a column.
|---|---|---| |---|---|---|
| `hostname` (required) | string | Identity. Matches `Computer.hostname` (case-insensitive), then falls back to `Asset.assetnumber`. New asset created if no match. | | `hostname` (required) | string | Identity. Matches `Computer.hostname` (case-insensitive), then falls back to `Asset.assetnumber`. New asset created if no match. |
| `machinenumber` | string | Identifies the MACHINE this PC drives, never the PC. A new PC always takes `assetnumber = hostname`, and an existing PC's `assetnumber` is left alone. The number resolves a machine asset and builds a PC -> machine `controls` relationship; an unknown number warns rather than creating a machine. The placeholder `9999` and empty string link nothing. See "Machine links" below. | | `machinenumber` | string | Identifies the MACHINE this PC drives, never the PC. A new PC always takes `assetnumber = hostname`, and an existing PC's `assetnumber` is left alone. The number resolves a machine asset and builds a PC -> machine `controls` relationship; an unknown number warns rather than creating a machine. The placeholder `9999` and empty string link nothing. See "Machine links" below. |
| `measuringtoolid` | string | Identifies the INSTRUMENT this PC drives, read from `C:\Enrollment\measuringtool-id.txt`. SEPARATE from `machinenumber` on purpose: "which bay is this" and "which instrument is this" are different facts, and `machinenumber` is what GE-Enforce `TargetMachineNumbers` gates on, so repointing it at a tool would silently stop every bay-gated manifest entry from matching. The value is an existing measuring tool's `assetnumber`. An unknown value warns rather than minting a tool, and a value that resolves to something which is NOT a measuring tool is refused with the asset named. See "Measuring tool links" below. | | `deviceid` | string | Identifies the DEVICE hanging off this PC, read from `C:\Enrollment\asset-id.txt`. ONE file for every bay with no NTLARS/eDNC `MachineNo` registry to identify it. It names the asset and nothing else - it does NOT say what kind of device it is, because the pc-type already does - so the same file serves a part marker, a Keyence, a Genspect or anything declared later. SEPARATE from `machinenumber` on purpose: "which bay is this" and "which device is this" are different facts, and `machinenumber` is what GE-Enforce `TargetMachineNumbers` gates on, so naming a device there would silently stop every bay-gated manifest entry from matching. An unknown value warns rather than minting, and a value resolving to the wrong KIND of asset is refused with the asset named. See "PC -> device links" below. |
| `measuringtoolid` | string | The 0.12.0 name for the same thing, before the file was generalised, read from `C:\Enrollment\measuringtool-id.txt`. Accepted as an alias so a bay already staged with that file keeps reporting; `deviceid` wins when both arrive. Prefer `asset-id.txt` for anything new. |
| `pctype` | string | `gea-shopfloor-*` imaging type -> `Computer.computertypeid` via the configurable `pctypemap` settings. Unmapped value -> warning, not error. | | `pctype` | string | `gea-shopfloor-*` imaging type -> `Computer.computertypeid` via the configurable `pctypemap` settings. Unmapped value -> warning, not error. |
| `pcsubtype` | string | Accepted but not stored yet -> warning. | | `pcsubtype` | string | Accepted but not stored yet -> warning. |
| `serialnumber` | string | `Asset.serialnumber`. | | `serialnumber` | string | `Asset.serialnumber`. |
@@ -264,42 +265,53 @@ so control still follows from controlling the marker, without two markers
contesting a link only one can hold. Backups from a marker PC file against the contesting a link only one can hold. Backups from a marker PC file against the
marker rather than the operation. marker rather than the operation.
### PC -> measuring tool links ### PC -> device links (`asset-id.txt`)
A metrology pc-type (CMM, Keyence, Genspect, wax-and-trace) means the PC drives Some pc-types mean the PC drives an attached DEVICE: a CMM, Keyence, Genspect or
an attached instrument. The collector links it PC -> tool as `controls`, tagged wax-trace instrument, or a part marker. `SUBORDINATE_DEVICE_MAP` in
`assetrelationships.label = 'collector:measuringtool'`. `plugins/computers/pctypemap.py` declares which, and a site can add its own
through a `subordinatedevice_<pctype>` setting without a code change.
`C:\Enrollment\asset-id.txt` names that device. One file for every bay that has
no NTLARS/eDNC `MachineNo` registry to identify it, holding one line: the
device's `assetnumber`. It does NOT record what kind of device it is - the
pc-type already does - so a bay declared later needs no new file and no client
change.
Resolution runs most-stable-identity first, and ADOPTS before it mints: Resolution runs most-stable-identity first, and ADOPTS before it mints:
1. **`measuringtoolid`** - the instrument named in 1. **`deviceid`** - the asset named in `asset-id.txt`.
`C:\Enrollment\measuringtool-id.txt`. Explicit, survives a PC swap, and it is 2. A prior collector link from this PC, reactivated.
what a Keyence or Genspect bay has instead of a bay id. 3. An existing device this PC already controls that the collector did NOT
2. A prior collector link on this PC, reactivated. create - a legacy `MT-####` row, or one somebody made by hand. Adopting
3. An existing tool this PC already controls that the collector did NOT create - stamps the label so it is ours from then on.
a legacy `MT-####` row, or one somebody made by hand. Adopting stamps the 4. For a measuring tool only: the reported machine number, when it resolves to
label so it is ours from then on. one. This is the CMM case, where `cmmid.txt` already reports `CMM4`.
4. The reported machine number, when it resolves to a measuring tool. This is
the CMM case: `cmmid.txt` already reports `CMM4`.
5. Mint, only when none of the above matched. 5. Mint, only when none of the above matched.
**Why minting is last.** It derives the asset number from the HOSTNAME, so a **Why the file exists, and why it is step 1.** Every other identity here is
permanent instrument inherited the identity of whichever PC drove it that week: derived from the PC. Step 2 looks for a link from THIS PC asset; step 5 mints
replace the PC and either the number lies or a second tool appears for the same `<PC number>-<SUFFIX>`, and a PC's asset number is its hostname. All of them
physical unit. It also could not see a tool it had not created itself, so at the survive a re-image and NONE of them survives a PC SWAP: a new hostname is a new
reference site 43 legacy `MT-####` tools ended up shadowed by minted PC asset with no prior link and a predicted number that has never existed, so
`<HOST>-CMM` twins, three records deep in places. Treat anything minted as a the same physical device gets a second record while the first keeps its config
placeholder to be reconciled. and backup history under a dead PC's name. That is how 43 legacy `MT-####`
tools ended up shadowed by minted twins, three records deep in places. Treat
anything minted as a placeholder to be reconciled.
Two guards on the file's contents: Two guards on the file's contents:
- An **unresolvable** value warns and links nothing, rather than inventing a - An **unresolvable** value warns and links nothing, rather than inventing a
phantom instrument nobody can account for. phantom device nobody can account for.
- A value that resolves to an asset which is **not a measuring tool** is refused, - A value resolving to the **wrong kind** of asset is refused, naming the asset
naming the asset it hit. A machine number pasted into `measuringtool-id.txt` it hit. A machine number pasted into `asset-id.txt` would otherwise file that
would otherwise link the PC to that MACHINE under a measuring-tool label - a MACHINE under a device label the collector also owns - a link that reads as an
link that reads as an instrument everywhere downstream, on a row the machine instrument, or as a marker, everywhere downstream.
sync also owns.
A part marker additionally stays `partof` the operation from `machinenumber`,
because several markers serve one operation. Naming the marker in `asset-id.txt`
changes which marker asset the PC controls; it does not change which operation
that marker files under.
### PC -> printer relationship sync ### PC -> printer relationship sync
@@ -346,7 +358,7 @@ The fleet's classic-ASP reporter posts form fields to
|---|---| |---|---|
| `hostname` | `hostname` | | `hostname` | `hostname` |
| `machineNo` | `machinenumber` | | `machineNo` | `machinenumber` |
| `measuringToolId` | `measuringtoolid` | | `deviceId` | `deviceid` |
| `pcType` | `pctype` | | `pcType` | `pctype` |
| `serialNumber` | `serialnumber` | | `serialNumber` | `serialnumber` |
| `loggedInUser` | `loggedinuser` | | `loggedInUser` | `loggedinuser` |
@@ -638,15 +650,20 @@ function Get-ShopdbMachineNumber {
return $machineNumber return $machineNumber
} }
function Get-ShopdbMeasuringToolId { function Get-ShopdbDeviceId {
# The INSTRUMENT this PC drives, for a metrology bay. Deliberately its own # The DEVICE hanging off this PC, for any bay with no MachineNo registry.
# file: machinenumber is what GE-Enforce TargetMachineNumbers gates on, so # Names the asset and not its type - the pc-type already says that.
# naming a tool there would silently stop bay-gated entries from matching. # Deliberately its own file: machinenumber is what GE-Enforce
if (Test-Path 'C:\Enrollment\measuringtool-id.txt') { # TargetMachineNumbers gates on, so naming a device there would silently
try { # stop bay-gated entries from matching.
$v = (Get-Content 'C:\Enrollment\measuringtool-id.txt' -First 1 -ErrorAction Stop).Trim() foreach ($f in @('C:\Enrollment\asset-id.txt',
if ($v) { return $v } 'C:\Enrollment\measuringtool-id.txt')) { # 2nd = pre-0.13
} catch {} if (Test-Path $f) {
try {
$v = (Get-Content $f -First 1 -ErrorAction Stop).Trim()
if ($v) { return $v }
} catch {}
}
} }
return '' return ''
} }
@@ -710,7 +727,7 @@ function Send-ShopdbCollectorReport {
if (-not $hostname) { $hostname = $env:COMPUTERNAME } if (-not $hostname) { $hostname = $env:COMPUTERNAME }
$machineNumber = Get-ShopdbMachineNumber $machineNumber = Get-ShopdbMachineNumber
$measuringToolId = Get-ShopdbMeasuringToolId $deviceId = Get-ShopdbDeviceId
$ipAddress = Get-ShopdbCorpIPv4 $ipAddress = Get-ShopdbCorpIPv4
$serialNumber = '' $serialNumber = ''
@@ -776,7 +793,7 @@ function Send-ShopdbCollectorReport {
if ($defaultPrinter) { $payload['defaultprinter'] = $defaultPrinter } if ($defaultPrinter) { $payload['defaultprinter'] = $defaultPrinter }
if ($printerIds.Count) { $payload['printers'] = $printerIds } if ($printerIds.Count) { $payload['printers'] = $printerIds }
if ($machineNumber) { $payload['machinenumber'] = $machineNumber } if ($machineNumber) { $payload['machinenumber'] = $machineNumber }
if ($measuringToolId) { $payload['measuringtoolid'] = $measuringToolId } if ($deviceId) { $payload['deviceid'] = $deviceId }
if ($pcType) { $payload['pctype'] = $pcType } if ($pcType) { $payload['pctype'] = $pcType }
if ($pcSubType) { $payload['pcsubtype'] = $pcSubType } if ($pcSubType) { $payload['pcsubtype'] = $pcSubType }
if ($serialNumber) { $payload['serialnumber'] = $serialNumber } if ($serialNumber) { $payload['serialnumber'] = $serialNumber }

View File

@@ -172,7 +172,7 @@ try {
# 2. C:\Enrollment\cmm\cmmid.txt - CMM bay id (e.g. CMM3). # 2. C:\Enrollment\cmm\cmmid.txt - CMM bay id (e.g. CMM3).
# 3. C:\Enrollment\machine-number.txt - imaging value. # 3. C:\Enrollment\machine-number.txt - imaging value.
# keyence / genspect / part-marker have no per-bay id -> no machineNo sent. # keyence / genspect / part-marker have no per-bay id -> no machineNo sent.
# A metrology bay names its INSTRUMENT instead, in measuringtool-id.txt below. # Those bays name their DEVICE instead, in asset-id.txt below.
$machineNo = '' $machineNo = ''
foreach ($regPath in @( foreach ($regPath in @(
'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General', 'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General',
@@ -237,23 +237,43 @@ try {
Log "WARN could not read logged-in user: $($_.Exception.Message)" Log "WARN could not read logged-in user: $($_.Exception.Message)"
} }
# The INSTRUMENT this PC drives, named by its own enrollment file. Deliberately # The DEVICE hanging off this PC - the ONE enrollment file for every bay that
# NOT machine-number.txt: machinenumber answers "which bay is this" and is what # has no NTLARS/eDNC MachineNo registry to identify it. It names the asset and
# GE-Enforce TargetMachineNumbers gates on, so pointing it at a tool would # nothing else: it does not say what kind of device that is, because the pc-type
# silently stop every bay-gated manifest entry from matching. A Keyence or # already does, so the same file serves a part marker, a Keyence, a Genspect or
# Genspect bay has an instrument and no bay id, which is the case this exists # anything added later without a new file or a client change.
# for; a CMM already reports its bay id through cmmid.txt and the server falls
# back to that.
# #
# The server ADOPTS the named instrument and refuses to mint one from a value it # Deliberately NOT machine-number.txt. machinenumber answers "which bay is
# cannot resolve, so a typo here warns instead of inventing a phantom tool. # this" and is what GE-Enforce TargetMachineNumbers gates on, so naming a device
$measuringToolId = '' # there would silently stop every bay-gated manifest entry from matching.
$mtFile = 'C:\Enrollment\measuringtool-id.txt' #
if (Test-Path -LiteralPath $mtFile) { # WHY A FILE AT ALL: every other identity the server can fall back to is derived
# from the PC - a prior link from this PC, or an asset number built from this
# hostname - so none of them survive a PC SWAP. The replacement box mints a
# SECOND record for the same physical device while the first keeps its history
# under a dead PC's name. This file is what survives.
#
# The server ADOPTS the named asset, refuses a value it cannot resolve, and
# refuses one that resolves to the wrong kind of thing - so a typo warns instead
# of inventing a phantom device.
$deviceId = ''
$idFile = 'C:\Enrollment\asset-id.txt'
if (Test-Path -LiteralPath $idFile) {
try { try {
$measuringToolId = ([string](Get-Content -LiteralPath $mtFile -First 1 -ErrorAction Stop)).Trim() $deviceId = ([string](Get-Content -LiteralPath $idFile -First 1 -ErrorAction Stop)).Trim()
if ($measuringToolId) { Log "measuringToolId from ${mtFile}: $measuringToolId" } if ($deviceId) { Log "deviceId from ${idFile}: $deviceId" }
} catch { Log "WARN could not read ${mtFile}: $($_.Exception.Message)" } } catch { Log "WARN could not read ${idFile}: $($_.Exception.Message)" }
}
# 0.12.0 shipped measuringtool-id.txt before the file was generalised. Read it
# as a fallback so a bay already staged with one keeps reporting.
if (-not $deviceId) {
$mtFile = 'C:\Enrollment\measuringtool-id.txt'
if (Test-Path -LiteralPath $mtFile) {
try {
$deviceId = ([string](Get-Content -LiteralPath $mtFile -First 1 -ErrorAction Stop)).Trim()
if ($deviceId) { Log "deviceId from ${mtFile} (pre-0.13 name): $deviceId" }
} catch { Log "WARN could not read ${mtFile}: $($_.Exception.Message)" }
}
} }
# imaging pc-type (gea-shopfloor-*), read from the enrollment file. Sent only # imaging pc-type (gea-shopfloor-*), read from the enrollment file. Sent only
@@ -322,7 +342,7 @@ $body = @{ hostname = $hostname }
if ($serialNumber) { $body['serialnumber'] = $serialNumber } if ($serialNumber) { $body['serialnumber'] = $serialNumber }
if ($machineNo) { $body['machinenumber'] = $machineNo } if ($machineNo) { $body['machinenumber'] = $machineNo }
if ($pcType) { $body['pctype'] = $pcType } if ($pcType) { $body['pctype'] = $pcType }
if ($measuringToolId) { $body['measuringtoolid'] = $measuringToolId } if ($deviceId) { $body['deviceid'] = $deviceId }
if ($manufacturer) { $body['vendorname'] = $manufacturer } if ($manufacturer) { $body['vendorname'] = $manufacturer }
if ($model) { $body['modelnumber'] = $model } if ($model) { $body['modelnumber'] = $model }
if ($osVersion) { $body['osname'] = $osVersion } if ($osVersion) { $body['osname'] = $osVersion }
@@ -333,8 +353,8 @@ $body['lastcheckin'] = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:s
$json = $body | ConvertTo-Json -Compress -Depth 4 $json = $body | ConvertTo-Json -Compress -Depth 4
Log ("POST {0} host={1} serial={2} pcType={3} make={4} model={5} os={6} boot={7} machineNo={8} user={9} ip={10} toolId={11}" -f ` Log ("POST {0} host={1} serial={2} pcType={3} make={4} model={5} os={6} boot={7} machineNo={8} user={9} ip={10} deviceId={11}" -f `
$ApiUrl, $hostname, $serialNumber, $pcType, $manufacturer, $model, $osVersion, $lastBootTime, $machineNo, $loggedInUser, $corpIp, $measuringToolId) $ApiUrl, $hostname, $serialNumber, $pcType, $manufacturer, $model, $osVersion, $lastBootTime, $machineNo, $loggedInUser, $corpIp, $deviceId)
try { try {
$resp = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $json ` $resp = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $json `

View File

@@ -106,12 +106,24 @@ class ComputersPlugin(BasePlugin):
'fields': { 'fields': {
'hostname': {'type': 'string', 'required': True}, 'hostname': {'type': 'string', 'required': True},
'machinenumber': {'type': 'string'}, 'machinenumber': {'type': 'string'},
# The instrument this PC drives, named by the enrollment file # The DEVICE hanging off this PC, named by the enrollment file
# measuringtool-id.txt. SEPARATE from machinenumber on purpose: # asset-id.txt. One file for every bay that has no NTLARS/eDNC
# "which bay is this" and "which instrument is this" are # MachineNo registry to identify it, and it does NOT say what
# different facts, and machinenumber is what GE-Enforce # kind of device it is: the pc-type already does, through
# TargetMachineNumbers gates on - repointing it at a tool would # SUBORDINATE_DEVICE_MAP.
# silently stop every bay-gated manifest entry matching. #
# SEPARATE from machinenumber on purpose: "which bay is this"
# and "which device is this" are different facts, and
# machinenumber is what GE-Enforce TargetMachineNumbers gates
# on - repointing it at a device would silently stop every
# bay-gated manifest entry matching.
#
# Named deviceid rather than assetid because assetid is this
# contract's own RESPONSE field for a PC's integer primary key.
'deviceid': {'type': 'string'},
# Shipped in 0.12.0, before the file was generalised. Accepted
# as an alias so a bay staged with measuringtool-id.txt keeps
# reporting; deviceid wins when both arrive.
'measuringtoolid': {'type': 'string'}, 'measuringtoolid': {'type': 'string'},
'pctype': {'type': 'string'}, 'pctype': {'type': 'string'},
'pcsubtype': {'type': 'string'}, 'pcsubtype': {'type': 'string'},
@@ -394,8 +406,14 @@ class ComputersPlugin(BasePlugin):
# marker PC must not also claim the operation directly: several markers # marker PC must not also claim the operation directly: several markers
# serve one operation number, so direct claims would fight over it. The # serve one operation number, so direct claims would fight over it. The
# marker is partof the operation and control propagates along that rail. # marker is partof the operation and control propagates along that rail.
# asset-id.txt, falling back to the 0.12.0 field name. One value feeds
# BOTH device paths: the file names the device and nothing else, so
# which sync consumes it follows from the pc-type.
deviceid = (payload.get('deviceid')
or payload.get('measuringtoolid') or '').strip() or None
partmarkers = self._sync_partmarker(comp, pctype, machinenumber, partmarkers = self._sync_partmarker(comp, pctype, machinenumber,
warnings) warnings, deviceid=deviceid)
# PC -> machine link from the reported machine number. # PC -> machine link from the reported machine number.
if partmarkers: if partmarkers:
@@ -410,7 +428,7 @@ class ComputersPlugin(BasePlugin):
# get an attached MeasuringTool asset auto-created and linked. # get an attached MeasuringTool asset auto-created and linked.
measuringtoollinks = self._sync_measuringtool_link( measuringtoollinks = self._sync_measuringtool_link(
comp.asset, pctype, hostname, warnings, comp.asset, pctype, hostname, warnings,
measuringtoolid=payload.get('measuringtoolid'), measuringtoolid=deviceid,
machinenumber=machinenumber) machinenumber=machinenumber)
db.session.commit() db.session.commit()
@@ -750,7 +768,70 @@ class ComputersPlugin(BasePlugin):
'machinenumber': machine.assetnumber, 'machinenumber': machine.assetnumber,
'superseded': len(held)}] 'superseded': len(held)}]
def _sync_partmarker(self, comp, pctype, machinenumber, warnings): def _is_device_of_type(self, asset, spec):
"""Is this asset the kind of device the pc-type's spec describes?
asset-id.txt names a device and says nothing about its type, so the
type is what proves the file points somewhere sensible. A measuring
tool is checked against the measuring-tool set; anything else is
checked against its machine type, which is where a Part Marker lives.
"""
if asset is None:
return False
if spec.get('assettype') == 'measuring_tool':
return asset.assetid in self._measuringtool_assetids()
try:
from plugins.machines.models import Machine, MachineType
except ImportError:
return False
row = (db.session.query(MachineType.machinetype)
.join(Machine, Machine.machinetypeid == MachineType.machinetypeid)
.filter(Machine.assetid == asset.assetid).first())
return bool(row) and row[0] == spec['typename']
def _ensure_device_rows(self, deviceasset, spec, pcasset, controls, label,
warnings):
"""Get-or-create the extension row and the PC -> device control link.
Shared by the named-device and minted paths: an adopted asset may have
no extension row, and the control link may already exist under another
label, so both are get-or-create rather than insert.
"""
from shopdb.api import AssetRelationship
try:
from plugins.machines.models import Machine, MachineType
except ImportError:
warnings.append('machines plugin unavailable; {} device skipped'
.format(spec['typename']))
return
if not Machine.query.filter_by(assetid=deviceasset.assetid).first():
devicetype = MachineType.query.filter_by(
machinetype=spec['typename']).first()
if not devicetype:
devicetype = MachineType(machinetype=spec['typename'],
description=spec.get('description'))
db.session.add(devicetype)
db.session.flush()
db.session.add(Machine(assetid=deviceasset.assetid,
machinetypeid=devicetype.machinetypeid))
controlrow = AssetRelationship.query.filter_by(
sourceassetid=pcasset.assetid,
targetassetid=deviceasset.assetid,
relationshiptypeid=controls.relationshiptypeid).first()
if controlrow is not None:
controlrow.isactive = True
if not controlrow.label:
controlrow.label = label
else:
db.session.add(AssetRelationship(
sourceassetid=pcasset.assetid,
targetassetid=deviceasset.assetid,
relationshiptypeid=controls.relationshiptypeid,
label=label,
isactive=True))
def _sync_partmarker(self, comp, pctype, machinenumber, warnings,
deviceid=None):
"""Give a part-marker PC a marker asset of its own, under its operation. """Give a part-marker PC a marker asset of its own, under its operation.
Several Telesis markers serve one operation number - 0613, 0615 and Several Telesis markers serve one operation number - 0613, 0615 and
@@ -760,10 +841,19 @@ class ComputersPlugin(BasePlugin):
question about an individual marker (how many are there, which port, question about an individual marker (how many are there, which port,
which one failed) could be asked at all. which one failed) could be asked at all.
One marker per PC, so the PC identifies the marker and the collector can One marker per PC. The marker is a machine asset of type Part Marker,
mint it the same way it already mints a CMM or a Keyence unit for a the PC `controls` it, and the marker is `partof` the operation it
metrology PC. The marker is a machine asset of type Part Marker, the PC serves.
`controls` it, and the marker is `partof` the operation it serves.
IDENTITY COMES FROM asset-id.txt FIRST, because everything else here is
derived from the PC and a PC is not permanent. Reuse looks for a prior
link from THIS PC asset and adoption looks up `<PC number>-PARTMARKER`,
so both survive a re-image and neither survives a PC SWAP: a new
hostname is a new PC asset with no prior link and a predicted number
that has never existed, so the same physical Telesis unit gets a second
record while the first keeps its config and backup history under a dead
PC's name. That is the failure the metrology path already learned from,
where minting from the hostname left 43 instruments shadowed by twins.
That last rail is why the PC does not also claim the operation directly: That last rail is why the PC does not also claim the operation directly:
`controls` propagates through `partof` (seeded in reference-data), so `controls` propagates through `partof` (seeded in reference-data), so
@@ -801,8 +891,26 @@ class ComputersPlugin(BasePlugin):
label = spec['label'] label = spec['label']
# Reuse this PC's existing device before minting one, so a re-image # --- 1. an explicitly named device wins over everything -------------
# never leaves a second device behind for the same physical unit. named = (deviceid or '').strip()
namedasset = None
if named:
candidate = self._asset_by_number(named)
if candidate is None:
# Warn rather than invent: a typo must not mint a phantom
# device that nobody can account for.
warnings.append(
'no asset for device {!r}; not linked'.format(named))
elif not self._is_device_of_type(candidate, spec):
# The name resolved, but not to this pc-type's device. Refuse
# and say which asset it hit, rather than filing an unrelated
# asset under a device label the collector also owns.
warnings.append(
'asset {!r} is not a {}; not linked'.format(
candidate.assetnumber, spec['typename']))
else:
namedasset = candidate
existing = AssetRelationship.query.filter( existing = AssetRelationship.query.filter(
AssetRelationship.sourceassetid == pcasset.assetid, AssetRelationship.sourceassetid == pcasset.assetid,
AssetRelationship.relationshiptypeid == controls.relationshiptypeid, AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
@@ -811,7 +919,18 @@ class ComputersPlugin(BasePlugin):
reuse = next((rel for rel in existing if rel.isactive), None) \ reuse = next((rel for rel in existing if rel.isactive), None) \
or (existing[0] if existing else None) or (existing[0] if existing else None)
if reuse: if namedasset is not None:
# The file is authoritative. A PC that previously minted its own
# marker and is now told the real one keeps only the named link;
# the stale link is archived by the one-marker-per-PC sweep below.
markerasset = namedasset
reuse = next((rel for rel in existing
if rel.targetassetid == namedasset.assetid), None)
if reuse is not None:
reuse.isactive = True
self._ensure_device_rows(markerasset, spec, pcasset, controls,
label, warnings)
elif reuse:
reuse.isactive = True reuse.isactive = True
markerasset = db.session.get(Asset, reuse.targetassetid) markerasset = db.session.get(Asset, reuse.targetassetid)
else: else:
@@ -852,26 +971,8 @@ class ComputersPlugin(BasePlugin):
db.session.add(markerasset) db.session.add(markerasset)
db.session.flush() db.session.flush()
# The extension row may be missing on an adopted asset, and the link self._ensure_device_rows(markerasset, spec, pcasset, controls,
# may already exist under another label - both get-or-create. label, warnings)
if not Machine.query.filter_by(assetid=markerasset.assetid).first():
db.session.add(Machine(assetid=markerasset.assetid,
machinetypeid=devicetype.machinetypeid))
controlrow = AssetRelationship.query.filter_by(
sourceassetid=pcasset.assetid,
targetassetid=markerasset.assetid,
relationshiptypeid=controls.relationshiptypeid).first()
if controlrow is not None:
controlrow.isactive = True
if not controlrow.label:
controlrow.label = label
else:
db.session.add(AssetRelationship(
sourceassetid=pcasset.assetid,
targetassetid=markerasset.assetid,
relationshiptypeid=controls.relationshiptypeid,
label=label,
isactive=True))
# One marker per PC: archive any other collector marker link. # One marker per PC: archive any other collector marker link.
for rel in existing: for rel in existing:

View File

@@ -0,0 +1,207 @@
"""asset-id.txt names the device, and that name survives a PC swap.
Every other identity the collector has for a subordinate device is derived from
the PC: reuse looks for a prior link from THIS PC asset, and adoption looks up
`<PC number>-<SUFFIX>`. Both survive a re-image and neither survives a swap - a
new hostname is a new PC asset with no prior link and a predicted number that
has never existed, so the same physical device gets a second record while the
first keeps its history under a dead PC's name.
That is the failure that left 43 measuring tools shadowed by minted twins. These
tests pin the fix for BOTH device families, because the part-marker path was
modelled on the metrology path as it stood before it was fixed.
"""
import pytest
from shopdb.core.models import Asset, AssetType
from shopdb.core.models.relationship import RelationshipType, AssetRelationship
KEY = 'deviceid-key'
@pytest.fixture
def collector_key(app):
old = app.config.get('COLLECTOR_API_KEY')
app.config['COLLECTOR_API_KEY'] = KEY
yield KEY
app.config['COLLECTOR_API_KEY'] = old
@pytest.fixture
def rig(db):
for name in ('computer', 'machine', 'measuring_tool'):
if not AssetType.query.filter_by(assettype=name).first():
db.session.add(AssetType(assettype=name))
for name in ('controls', 'partof'):
if not RelationshipType.query.filter_by(relationshiptype=name).first():
db.session.add(RelationshipType(relationshiptype=name))
db.session.commit()
def _report(client, key, hostname, pctype, **extra):
payload = {'hostname': hostname, 'pctype': pctype}
payload.update(extra)
return client.post('/api/collector/computers', json=payload,
headers={'X-API-Key': key})
def _asset(db, assetnumber, assettype='machine'):
"""A bare asset: an operation, or something that is NOT a device."""
at = AssetType.query.filter_by(assettype=assettype).first()
asset = Asset(assetnumber=assetnumber, assettypeid=at.assettypeid)
db.session.add(asset)
db.session.commit()
return asset
def _marker(db, assetnumber):
"""A Part Marker the collector did NOT create - the real unit on the floor.
The extension row and machine type are what make it a marker; a bare asset
of the right number is deliberately refused, which the wrong-type test pins.
"""
from plugins.machines.models import Machine, MachineType
at = AssetType.query.filter_by(assettype='machine').first()
mtype = MachineType.query.filter_by(machinetype='Part Marker').first()
if mtype is None:
mtype = MachineType(machinetype='Part Marker')
db.session.add(mtype)
db.session.flush()
asset = Asset(assetnumber=assetnumber, assettypeid=at.assettypeid)
db.session.add(asset)
db.session.flush()
db.session.add(Machine(assetid=asset.assetid,
machinetypeid=mtype.machinetypeid))
db.session.commit()
return asset
def _tool(db, assetnumber):
"""A measuring tool the collector did NOT create."""
from plugins.measuringtools.models import MeasuringTool
at = AssetType.query.filter_by(assettype='measuring_tool').first()
asset = Asset(assetnumber=assetnumber, assettypeid=at.assettypeid)
db.session.add(asset)
db.session.flush()
db.session.add(MeasuringTool(assetid=asset.assetid))
db.session.commit()
return asset
def _controlled(pcname, label):
"""Asset numbers this PC controls under a collector label."""
pc = Asset.query.filter(Asset.assetnumber.ilike(pcname)).first()
if pc is None:
return []
rels = AssetRelationship.query.filter_by(
sourceassetid=pc.assetid, label=label, isactive=True).all()
return sorted(Asset.query.filter_by(assetid=r.targetassetid).first().assetnumber
for r in rels)
# --------------------------------------------------------------- part markers
def test_a_pc_swap_does_not_mint_a_second_marker(client, db, rig, collector_key):
"""THE case this exists for. Same physical marker, two different PCs."""
_asset(db, '0613')
marker = _marker(db, 'PM-0613-A')
first = _report(client, collector_key, 'FMARK100',
pctype='gea-shopfloor-partmarker',
machinenumber='0613', deviceid='PM-0613-A')
assert first.status_code in (200, 201), first.get_data(as_text=True)[:300]
# The bay's PC is replaced. New hostname, same marker named in asset-id.txt.
second = _report(client, collector_key, 'FMARK200',
pctype='gea-shopfloor-partmarker',
machinenumber='0613', deviceid='PM-0613-A')
assert second.status_code in (200, 201), second.get_data(as_text=True)[:300]
assert Asset.query.filter_by(assetnumber='FMARK100-PARTMARKER').first() is None
assert Asset.query.filter_by(assetnumber='FMARK200-PARTMARKER').first() is None
assert _controlled('FMARK200', 'collector:partmarker') == ['PM-0613-A']
assert Asset.query.filter_by(assetnumber='PM-0613-A').count() == 1
assert marker.assetid == Asset.query.filter_by(
assetnumber='PM-0613-A').first().assetid
def test_without_the_file_a_swap_still_mints_the_old_way(client, db, rig,
collector_key):
"""The unfixed behaviour, pinned so the file's value stays visible."""
_asset(db, '0614')
assert _report(client, collector_key, 'FMARK300',
pctype='gea-shopfloor-partmarker',
machinenumber='0614').status_code in (200, 201)
assert _report(client, collector_key, 'FMARK400',
pctype='gea-shopfloor-partmarker',
machinenumber='0614').status_code in (200, 201)
assert Asset.query.filter_by(assetnumber='FMARK300-PARTMARKER').first()
assert Asset.query.filter_by(assetnumber='FMARK400-PARTMARKER').first()
def test_an_unknown_device_warns_and_links_nothing(client, db, rig,
collector_key):
_asset(db, '0616')
resp = _report(client, collector_key, 'FMARK500',
pctype='gea-shopfloor-partmarker',
machinenumber='0616', deviceid='PM-TYPO')
assert resp.status_code in (200, 201)
assert Asset.query.filter_by(assetnumber='PM-TYPO').first() is None
warnings = ' '.join(resp.get_json()['data'].get('warnings', []))
assert 'PM-TYPO' in warnings
def test_a_device_of_the_wrong_type_is_refused(client, db, rig, collector_key):
"""A machine number pasted into asset-id.txt must not become a marker."""
_asset(db, '0617')
_asset(db, 'PLAIN-MACHINE')
resp = _report(client, collector_key, 'FMARK600',
pctype='gea-shopfloor-partmarker',
machinenumber='0617', deviceid='PLAIN-MACHINE')
assert resp.status_code in (200, 201)
assert _controlled('FMARK600', 'collector:partmarker') != ['PLAIN-MACHINE']
warnings = ' '.join(resp.get_json()['data'].get('warnings', []))
assert 'PLAIN-MACHINE' in warnings
def test_repeat_cycles_with_the_file_are_stable(client, db, rig, collector_key):
_asset(db, '0618')
_marker(db, 'PM-0618-A')
codes = [_report(client, collector_key, 'FMARK700',
pctype='gea-shopfloor-partmarker',
machinenumber='0618', deviceid='PM-0618-A').status_code
for _ in range(3)]
assert codes == [codes[0]] * 3, codes
assert _controlled('FMARK700', 'collector:partmarker') == ['PM-0618-A']
# ------------------------------------------------------------ measuring tools
def test_the_same_file_serves_a_metrology_bay(client, db, rig, collector_key):
"""One file, no device type in it: the pc-type decides which sync uses it."""
_tool(db, 'MT-9001')
resp = _report(client, collector_key, 'KEYENCE100',
pctype='gea-shopfloor-keyence', deviceid='MT-9001')
assert resp.status_code in (200, 201), resp.get_data(as_text=True)[:300]
assert _controlled('KEYENCE100', 'collector:measuringtool') == ['MT-9001']
assert Asset.query.filter_by(assetnumber='KEYENCE100-KEYENCE').first() is None
def test_the_0120_field_name_still_works(client, db, rig, collector_key):
"""measuringtool-id.txt shipped in 0.12.0; a staged bay keeps reporting."""
_tool(db, 'MT-9002')
resp = _report(client, collector_key, 'KEYENCE200',
pctype='gea-shopfloor-keyence', measuringtoolid='MT-9002')
assert resp.status_code in (200, 201)
assert _controlled('KEYENCE200', 'collector:measuringtool') == ['MT-9002']
def test_deviceid_wins_when_both_arrive(client, db, rig, collector_key):
_tool(db, 'MT-9003')
_tool(db, 'MT-9004')
resp = _report(client, collector_key, 'KEYENCE300',
pctype='gea-shopfloor-keyence',
deviceid='MT-9003', measuringtoolid='MT-9004')
assert resp.status_code in (200, 201)
assert _controlled('KEYENCE300', 'collector:measuringtool') == ['MT-9003']