diff --git a/docs/COLLECTOR-INTEGRATION.md b/docs/COLLECTOR-INTEGRATION.md index 633949e..7f343c8 100644 --- a/docs/COLLECTOR-INTEGRATION.md +++ b/docs/COLLECTOR-INTEGRATION.md @@ -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_` 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 -`-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 +`-`, 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 } diff --git a/plugins/computers/client/Report-AssetToShopDB.ps1 b/plugins/computers/client/Report-AssetToShopDB.ps1 index 646e5bc..40b3079 100644 --- a/plugins/computers/client/Report-AssetToShopDB.ps1 +++ b/plugins/computers/client/Report-AssetToShopDB.ps1 @@ -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 ` diff --git a/plugins/computers/plugin.py b/plugins/computers/plugin.py index f174b76..4d55054 100644 --- a/plugins/computers/plugin.py +++ b/plugins/computers/plugin.py @@ -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 `-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: diff --git a/tests/test_plugins/test_collector_deviceid.py b/tests/test_plugins/test_collector_deviceid.py new file mode 100644 index 0000000..5a721af --- /dev/null +++ b/tests/test_plugins/test_collector_deviceid.py @@ -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 +`-`. 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']