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. |
| `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. |
| `pcsubtype` | string | Accepted but not stored yet -> warning. |
| `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
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
an attached instrument. The collector links it PC -> tool as `controls`, tagged
`assetrelationships.label = 'collector:measuringtool'`.
Some pc-types mean the PC drives an attached DEVICE: a CMM, Keyence, Genspect or
wax-trace instrument, or a part marker. `SUBORDINATE_DEVICE_MAP` in
`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:
1. **`measuringtoolid`** - the instrument named in
`C:\Enrollment\measuringtool-id.txt`. Explicit, survives a PC swap, and it is
what a Keyence or Genspect bay has instead of a bay id.
2. A prior collector link on this PC, reactivated.
3. An existing tool this PC already controls that the collector did NOT create -
a legacy `MT-####` row, or one somebody made by hand. Adopting stamps the
label so it is ours from then on.
4. The reported machine number, when it resolves to a measuring tool. This is
the CMM case: `cmmid.txt` already reports `CMM4`.
1. **`deviceid`** - the asset named in `asset-id.txt`.
2. A prior collector link from this PC, reactivated.
3. An existing device this PC already controls that the collector did NOT
create - a legacy `MT-####` row, or one somebody made by hand. Adopting
stamps the label so it is ours from then on.
4. For a measuring tool only: the reported machine number, when it resolves to
one. This is the CMM case, where `cmmid.txt` already reports `CMM4`.
5. Mint, only when none of the above matched.
**Why minting is last.** It derives the asset number from the HOSTNAME, so a
permanent instrument inherited the identity of whichever PC drove it that week:
replace the PC and either the number lies or a second tool appears for the same
physical unit. It also could not see a tool it had not created itself, so at the
reference site 43 legacy `MT-####` tools ended up shadowed by minted
`<HOST>-CMM` twins, three records deep in places. Treat anything minted as a
placeholder to be reconciled.
**Why the file exists, and why it is step 1.** Every other identity here is
derived from the PC. Step 2 looks for a link from THIS PC asset; step 5 mints
`<PC number>-<SUFFIX>`, and a PC's asset number is its hostname. All of them
survive a re-image and NONE of them 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 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:
- An **unresolvable** value warns and links nothing, rather than inventing a
phantom instrument nobody can account for.
- A value that resolves to an asset which is **not a measuring tool** is refused,
naming the asset it hit. A machine number pasted into `measuringtool-id.txt`
would otherwise link the PC to that MACHINE under a measuring-tool label - a
link that reads as an instrument everywhere downstream, on a row the machine
sync also owns.
phantom device nobody can account for.
- A value resolving to the **wrong kind** of asset is refused, naming the asset
it hit. A machine number pasted into `asset-id.txt` would otherwise file that
MACHINE under a device label the collector also owns - a link that reads as an
instrument, or as a marker, everywhere downstream.
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
@@ -346,7 +358,7 @@ The fleet's classic-ASP reporter posts form fields to
|---|---|
| `hostname` | `hostname` |
| `machineNo` | `machinenumber` |
| `measuringToolId` | `measuringtoolid` |
| `deviceId` | `deviceid` |
| `pcType` | `pctype` |
| `serialNumber` | `serialnumber` |
| `loggedInUser` | `loggedinuser` |
@@ -638,15 +650,20 @@ function Get-ShopdbMachineNumber {
return $machineNumber
}
function Get-ShopdbMeasuringToolId {
# The INSTRUMENT this PC drives, for a metrology bay. Deliberately its own
# file: machinenumber is what GE-Enforce TargetMachineNumbers gates on, so
# naming a tool there would silently stop bay-gated entries from matching.
if (Test-Path 'C:\Enrollment\measuringtool-id.txt') {
try {
$v = (Get-Content 'C:\Enrollment\measuringtool-id.txt' -First 1 -ErrorAction Stop).Trim()
if ($v) { return $v }
} catch {}
function Get-ShopdbDeviceId {
# The DEVICE hanging off this PC, for any bay with no MachineNo registry.
# Names the asset and not its type - the pc-type already says that.
# Deliberately its own file: machinenumber is what GE-Enforce
# TargetMachineNumbers gates on, so naming a device there would silently
# stop bay-gated entries from matching.
foreach ($f in @('C:\Enrollment\asset-id.txt',
'C:\Enrollment\measuringtool-id.txt')) { # 2nd = pre-0.13
if (Test-Path $f) {
try {
$v = (Get-Content $f -First 1 -ErrorAction Stop).Trim()
if ($v) { return $v }
} catch {}
}
}
return ''
}
@@ -710,7 +727,7 @@ function Send-ShopdbCollectorReport {
if (-not $hostname) { $hostname = $env:COMPUTERNAME }
$machineNumber = Get-ShopdbMachineNumber
$measuringToolId = Get-ShopdbMeasuringToolId
$deviceId = Get-ShopdbDeviceId
$ipAddress = Get-ShopdbCorpIPv4
$serialNumber = ''
@@ -776,7 +793,7 @@ function Send-ShopdbCollectorReport {
if ($defaultPrinter) { $payload['defaultprinter'] = $defaultPrinter }
if ($printerIds.Count) { $payload['printers'] = $printerIds }
if ($machineNumber) { $payload['machinenumber'] = $machineNumber }
if ($measuringToolId) { $payload['measuringtoolid'] = $measuringToolId }
if ($deviceId) { $payload['deviceid'] = $deviceId }
if ($pcType) { $payload['pctype'] = $pcType }
if ($pcSubType) { $payload['pcsubtype'] = $pcSubType }
if ($serialNumber) { $payload['serialnumber'] = $serialNumber }

View File

@@ -172,7 +172,7 @@ try {
# 2. C:\Enrollment\cmm\cmmid.txt - CMM bay id (e.g. CMM3).
# 3. C:\Enrollment\machine-number.txt - imaging value.
# 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 = ''
foreach ($regPath in @(
'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General',
@@ -237,23 +237,43 @@ try {
Log "WARN could not read logged-in user: $($_.Exception.Message)"
}
# The INSTRUMENT this PC drives, named by its own enrollment file. Deliberately
# NOT machine-number.txt: machinenumber answers "which bay is this" and is what
# GE-Enforce TargetMachineNumbers gates on, so pointing it at a tool would
# silently stop every bay-gated manifest entry from matching. A Keyence or
# Genspect bay has an instrument and no bay id, which is the case this exists
# for; a CMM already reports its bay id through cmmid.txt and the server falls
# back to that.
# The DEVICE hanging off this PC - the ONE enrollment file for every bay that
# has no NTLARS/eDNC MachineNo registry to identify it. It names the asset and
# nothing else: it does not say what kind of device that is, because the pc-type
# already does, so the same file serves a part marker, a Keyence, a Genspect or
# anything added later without a new file or a client change.
#
# The server ADOPTS the named instrument and refuses to mint one from a value it
# cannot resolve, so a typo here warns instead of inventing a phantom tool.
$measuringToolId = ''
$mtFile = 'C:\Enrollment\measuringtool-id.txt'
if (Test-Path -LiteralPath $mtFile) {
# Deliberately 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.
#
# 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 {
$measuringToolId = ([string](Get-Content -LiteralPath $mtFile -First 1 -ErrorAction Stop)).Trim()
if ($measuringToolId) { Log "measuringToolId from ${mtFile}: $measuringToolId" }
} catch { Log "WARN could not read ${mtFile}: $($_.Exception.Message)" }
$deviceId = ([string](Get-Content -LiteralPath $idFile -First 1 -ErrorAction Stop)).Trim()
if ($deviceId) { Log "deviceId from ${idFile}: $deviceId" }
} 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
@@ -322,7 +342,7 @@ $body = @{ hostname = $hostname }
if ($serialNumber) { $body['serialnumber'] = $serialNumber }
if ($machineNo) { $body['machinenumber'] = $machineNo }
if ($pcType) { $body['pctype'] = $pcType }
if ($measuringToolId) { $body['measuringtoolid'] = $measuringToolId }
if ($deviceId) { $body['deviceid'] = $deviceId }
if ($manufacturer) { $body['vendorname'] = $manufacturer }
if ($model) { $body['modelnumber'] = $model }
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
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 `
$ApiUrl, $hostname, $serialNumber, $pcType, $manufacturer, $model, $osVersion, $lastBootTime, $machineNo, $loggedInUser, $corpIp, $measuringToolId)
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, $deviceId)
try {
$resp = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $json `

View File

@@ -106,12 +106,24 @@ class ComputersPlugin(BasePlugin):
'fields': {
'hostname': {'type': 'string', 'required': True},
'machinenumber': {'type': 'string'},
# The instrument this PC drives, named by the enrollment file
# 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 - repointing it at a tool would
# silently stop every bay-gated manifest entry matching.
# The DEVICE hanging off this PC, named by the enrollment file
# asset-id.txt. One file for every bay that has no NTLARS/eDNC
# MachineNo registry to identify it, and it does NOT say what
# kind of device it is: the pc-type already does, through
# SUBORDINATE_DEVICE_MAP.
#
# 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'},
'pctype': {'type': 'string'},
'pcsubtype': {'type': 'string'},
@@ -394,8 +406,14 @@ class ComputersPlugin(BasePlugin):
# marker PC must not also claim the operation directly: several markers
# serve one operation number, so direct claims would fight over it. The
# 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,
warnings)
warnings, deviceid=deviceid)
# PC -> machine link from the reported machine number.
if partmarkers:
@@ -410,7 +428,7 @@ class ComputersPlugin(BasePlugin):
# get an attached MeasuringTool asset auto-created and linked.
measuringtoollinks = self._sync_measuringtool_link(
comp.asset, pctype, hostname, warnings,
measuringtoolid=payload.get('measuringtoolid'),
measuringtoolid=deviceid,
machinenumber=machinenumber)
db.session.commit()
@@ -750,7 +768,70 @@ class ComputersPlugin(BasePlugin):
'machinenumber': machine.assetnumber,
'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.
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,
which one failed) could be asked at all.
One marker per PC, so the PC identifies the marker and the collector can
mint it the same way it already mints a CMM or a Keyence unit for a
metrology PC. The marker is a machine asset of type Part Marker, the PC
`controls` it, and the marker is `partof` the operation it serves.
One marker per PC. The marker is a machine asset of type Part Marker,
the PC `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:
`controls` propagates through `partof` (seeded in reference-data), so
@@ -801,8 +891,26 @@ class ComputersPlugin(BasePlugin):
label = spec['label']
# Reuse this PC's existing device before minting one, so a re-image
# never leaves a second device behind for the same physical unit.
# --- 1. an explicitly named device wins over everything -------------
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(
AssetRelationship.sourceassetid == pcasset.assetid,
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
@@ -811,7 +919,18 @@ class ComputersPlugin(BasePlugin):
reuse = next((rel for rel in existing if rel.isactive), 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
markerasset = db.session.get(Asset, reuse.targetassetid)
else:
@@ -852,26 +971,8 @@ class ComputersPlugin(BasePlugin):
db.session.add(markerasset)
db.session.flush()
# The extension row may be missing on an adopted asset, and the link
# may already exist under another label - both get-or-create.
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))
self._ensure_device_rows(markerasset, spec, pcasset, controls,
label, warnings)
# One marker per PC: archive any other collector marker link.
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']