Device identity, collector enrollment file, and network device fixes

This commit is contained in:
2026-08-20 15:10:24 -04:00
parent 1b5b28acff
commit fa88ef0afe
6 changed files with 425 additions and 53 deletions

View File

@@ -172,6 +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.
# Those bays name their DEVICE instead, in asset-id.txt below.
$machineNo = ''
foreach ($regPath in @(
'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General',
@@ -236,6 +237,45 @@ try {
Log "WARN could not read logged-in user: $($_.Exception.Message)"
}
# 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.
#
# 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 {
$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
# when present; absent -> server leaves existing pctype untouched (a bare report
# never re-types a PC). Unmapped values return a warning, not an error.
@@ -302,6 +342,7 @@ $body = @{ hostname = $hostname }
if ($serialNumber) { $body['serialnumber'] = $serialNumber }
if ($machineNo) { $body['machinenumber'] = $machineNo }
if ($pcType) { $body['pctype'] = $pcType }
if ($deviceId) { $body['deviceid'] = $deviceId }
if ($manufacturer) { $body['vendorname'] = $manufacturer }
if ($model) { $body['modelnumber'] = $model }
if ($osVersion) { $body['osname'] = $osVersion }
@@ -312,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}" -f `
$ApiUrl, $hostname, $serialNumber, $pcType, $manufacturer, $model, $osVersion, $lastBootTime, $machineNo, $loggedInUser, $corpIp)
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

@@ -36,7 +36,9 @@
</tr>
</thead>
<tbody>
<tr v-for="item in machines" :key="item.assetid" class="clickable-row" @click="$router.push(`/machines/${item.machine?.machineid || item.assetid}`)">
<tr v-for="item in machines" :key="item.assetid"
:class="{ 'clickable-row': item.machine?.machineid }"
@click="item.machine?.machineid && $router.push(`/machines/${item.machine.machineid}`)">
<td>
{{ item.assetnumber }}<template v-if="item.dualpathpartner"> / {{ item.dualpathpartner.assetnumber }}</template>
</td>
@@ -58,12 +60,20 @@
</td>
<td>{{ item.locationname || '-' }}</td>
<td class="actions" @click.stop>
<!-- /machines/:id keys on machineid, the plugin extension id, NOT
the assetid. Falling back to the assetid lands on whichever
machine happens to carry that number: a wrong page that looks
right, which is worse than a 404 (see the same fix in
EnforcementReports and the warning in BackupHistory). With no
machineid there is no page to link to, so show nothing. -->
<router-link
:to="`/machines/${item.machine?.machineid || item.assetid}`"
v-if="item.machine?.machineid"
:to="`/machines/${item.machine.machineid}`"
class="btn btn-secondary btn-sm"
>
View
</router-link>
<span v-else>-</span>
</td>
</tr>
<tr v-if="machines.length === 0">

View File

@@ -33,22 +33,26 @@
{{ heroWarranty.label }}<template v-if="heroWarranty.enddate"> - {{ warrantyDate(heroWarranty.enddate) }}</template>
</span>
</div>
<!-- hero-detail / -label / -value are the styled classes every other
detail page uses. This page had its own detail-item / label / value
names, which match nothing in the stylesheet, so the label and the
value rendered as one run-together string. -->
<div class="hero-details">
<div class="detail-item" v-if="device.assetnumber">
<span class="label">Asset #</span>
<span class="value">{{ device.assetnumber }}</span>
<div class="hero-detail" v-if="device.assetnumber">
<span class="hero-detail-label">Asset #</span>
<span class="hero-detail-value">{{ device.assetnumber }}</span>
</div>
<div class="detail-item" v-if="device.serialnumber">
<span class="label">Serial</span>
<span class="value mono">{{ device.serialnumber }}</span>
<div class="hero-detail" v-if="device.serialnumber">
<span class="hero-detail-label">Serial</span>
<span class="hero-detail-value mono">{{ device.serialnumber }}</span>
</div>
<div class="detail-item" v-if="device.locationname">
<span class="label">Location</span>
<span class="value">{{ device.locationname }}</span>
<div class="hero-detail" v-if="device.locationname">
<span class="hero-detail-label">Location</span>
<span class="hero-detail-value">{{ device.locationname }}</span>
</div>
<div class="detail-item" v-if="device.businessunitname">
<span class="label">Business Unit</span>
<span class="value">{{ device.businessunitname }}</span>
<div class="hero-detail" v-if="device.businessunitname">
<span class="hero-detail-label">Business Unit</span>
<span class="hero-detail-value">{{ device.businessunitname }}</span>
</div>
</div>
<div class="hero-features" v-if="device.networkdevice">

View File

@@ -161,7 +161,7 @@
<label for="modelnumberid">Model</label>
<select id="modelnumberid" v-model="form.modelnumberid" class="form-control">
<option value="">Select Model</option>
<option v-for="m in models" :key="m.modelnumberid" :value="m.modelnumberid">
<option v-for="m in filteredModels" :key="m.modelnumberid" :value="m.modelnumberid">
{{ m.modelnumber }}
</option>
</select>
@@ -395,6 +395,15 @@ const generatedAssetNumber = computed(() => {
})
const vendors = ref([])
const models = ref([])
// Models belong to a vendor, so picking one narrows the list - the same
// behaviour PCForm, MachineForm and PrinterForm have. With no vendor chosen the
// whole catalogue shows, rather than an empty dropdown that reads as "no models
// exist" when it means "pick a vendor first".
const filteredModels = computed(() => {
if (!form.value.vendorid) return models.value
return models.value.filter(m => m.vendorid === form.value.vendorid)
})
const locations = ref([])
const statuses = ref([])
const businessUnits = ref([])

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']