computers: a part marker is its own asset, under the operation it serves
Several Telesis markers serve one operation number - 0613, 0615 and WJPRT each have more than one - so treating the operation as the marker collapsed separate devices into a single record. Their configurations differ by COM port, so in the backup history they overwrote each other, and no question about an individual marker could be asked at all: how many there are, which port one is on, which one failed. There is one marker per PC, which makes the PC the marker's identity, so the collector can mint the marker the same way it already mints a CMM or a Keyence unit for a metrology PC. A marker PC now gets a Part Marker machine asset, the PC controls it, and the marker is partof the operation whose number the PC reports. An operation holds any number of markers. A marker PC therefore does not claim the operation directly. controls propagates through partof, which reference-data already seeds, so control of the operation still follows from controlling its marker - without two markers contesting a link only one of them can hold. Backups from a marker PC resolve to the marker rather than the operation, and fall back to the machine number whenever the marker cannot be resolved: no hostname on the payload, a lean build without the computers or machines plugin, or a marker PC that has not reported to the computers collector yet. Filing under the operation is the old behaviour and beats rejecting a backup. Moving a marker to another operation archives the old membership rather than deleting it, so where a marker used to live stays answerable.
This commit is contained in:
14
CHANGELOG.md
14
CHANGELOG.md
@@ -83,6 +83,20 @@ ADR-007 and ADR-002.
|
||||
`computers_machinelink_alerts` setting that ships OFF: a site may legitimately
|
||||
run several PCs on one machine number, and there the alerts fire on correct
|
||||
data. The collector response warns either way.
|
||||
- A part marker is its own asset. Several Telesis markers can serve one
|
||||
operation number, so treating the operation as the marker collapsed separate
|
||||
devices into one record: their configurations, which differ by COM port,
|
||||
overwrote each other in the backup history, and no question about an
|
||||
individual marker - how many there are, which port one is on, which one
|
||||
failed - could be asked at all. There is one marker per PC, so the collector
|
||||
now mints a Part Marker machine asset for a marker PC the same way it already
|
||||
mints a CMM or a Keyence unit for a metrology PC: the PC `controls` the
|
||||
marker, and the marker is `partof` the operation it serves. Because `controls`
|
||||
propagates through `partof`, control of the operation still follows from
|
||||
controlling its marker, and two markers on one operation no longer contest a
|
||||
link only one of them can hold. Backups from a marker PC file against the
|
||||
marker. Moving a marker to another operation archives the old membership
|
||||
rather than deleting it.
|
||||
- Plugin contract 0.16.0: `get_settings_defaults()` lets a plugin declare the
|
||||
settings it owns (key, default, type, category, description, and whether an
|
||||
unauthenticated caller may read it). See `docs/PLUGIN-HOOKS.md`.
|
||||
|
||||
@@ -52,6 +52,52 @@ def byteshash(raw):
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def markerforsource(sourcehostname):
|
||||
"""Asset id of the part marker the reporting PC drives, or None.
|
||||
|
||||
A part-marker PC's config describes ITS marker, not the operation the
|
||||
marker serves. Several markers can serve one operation - 0613, 0615 and
|
||||
WJPRT each do - so filing by machine number put several devices' configs in
|
||||
one history where they overwrote each other. The computers collector gives
|
||||
each marker PC a marker asset (`collector:partmarker`), and that is what a
|
||||
backup from that PC belongs to.
|
||||
|
||||
Falls through to None, and so to the machine number, whenever anything is
|
||||
missing: no hostname, no computers or machines plugin on a lean build
|
||||
(ADR-014), or a marker PC that has not reported to the computers collector
|
||||
yet. Filing under the operation is the old behaviour and still better than
|
||||
rejecting the backup.
|
||||
"""
|
||||
from shopdb.api import db, AssetRelationship, RelationshipType
|
||||
|
||||
hostname = (sourcehostname or '').strip()
|
||||
if not hostname:
|
||||
return None
|
||||
try:
|
||||
from plugins.computers.models import Computer
|
||||
from plugins.computers.plugin import PARTMARKER_LINK_ORIGIN
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
computer = Computer.query.filter(Computer.hostname.ilike(hostname)).first()
|
||||
if computer is None or not computer.assetid:
|
||||
return None
|
||||
|
||||
controls = RelationshipType.query.filter_by(
|
||||
relationshiptype='controls').first()
|
||||
if controls is None:
|
||||
return None
|
||||
|
||||
link = (db.session.query(AssetRelationship)
|
||||
.filter(AssetRelationship.sourceassetid == computer.assetid,
|
||||
AssetRelationship.relationshiptypeid ==
|
||||
controls.relationshiptypeid,
|
||||
AssetRelationship.label == PARTMARKER_LINK_ORIGIN,
|
||||
AssetRelationship.isactive.is_(True))
|
||||
.first())
|
||||
return link.targetassetid if link else None
|
||||
|
||||
|
||||
class BackupKind:
|
||||
"""Base class. Subclasses override what applies to them."""
|
||||
|
||||
@@ -198,6 +244,10 @@ class NtlarsKind(BackupKind):
|
||||
def resolveassetid(self, payload):
|
||||
from shopdb.api import db, Asset
|
||||
|
||||
marker = markerforsource(payload.get('sourcehostname'))
|
||||
if marker is not None:
|
||||
return marker, None
|
||||
|
||||
machinenumber = (payload.get('machinenumber') or '').strip()
|
||||
if not machinenumber:
|
||||
return None, 'no machinenumber in payload'
|
||||
@@ -229,6 +279,10 @@ class PartMarkerKind(BackupKind):
|
||||
def resolveassetid(self, payload):
|
||||
from shopdb.api import db, Asset
|
||||
|
||||
marker = markerforsource(payload.get('sourcehostname'))
|
||||
if marker is not None:
|
||||
return marker, None
|
||||
|
||||
identifier = (payload.get('machinenumber')
|
||||
or payload.get('assetnumber') or '').strip()
|
||||
if not identifier:
|
||||
|
||||
@@ -43,6 +43,22 @@ METROLOGY_TOOL_MAP = {
|
||||
}
|
||||
|
||||
|
||||
# Imaging pc-type for a PC that drives a Telesis part marker. The marker is its
|
||||
# own asset, not a field on the PC: several markers can serve one operation
|
||||
# number (0613, 0615, WJPRT all have more than one), so filing their configs
|
||||
# under the operation collapsed separate devices into one record. One marker per
|
||||
# PC, so the PC identifies the marker - the same shape as METROLOGY_TOOL_MAP.
|
||||
PARTMARKER_PCTYPE = 'gea-shopfloor-partmarker'
|
||||
|
||||
# MachineType a collector-created marker is given.
|
||||
PARTMARKER_TYPENAME = 'Part Marker'
|
||||
|
||||
|
||||
def drives_partmarker(pctype):
|
||||
"""True when this imaging pc-type means the PC drives a part marker."""
|
||||
return (pctype or '').strip() == PARTMARKER_PCTYPE
|
||||
|
||||
|
||||
def metrology_tool_for(pctype):
|
||||
"""Return (typename, typedescription) if this pc-type drives a measuring
|
||||
tool, else None."""
|
||||
|
||||
@@ -34,6 +34,10 @@ MEASURINGTOOL_LINK_ORIGIN = 'collector:measuringtool'
|
||||
# touched.
|
||||
MACHINE_LINK_ORIGIN = 'collector:machine'
|
||||
|
||||
# Marker stamped on both links a part-marker PC produces: PC controls marker,
|
||||
# and marker partof the operation it serves. Same archive discipline again.
|
||||
PARTMARKER_LINK_ORIGIN = 'collector:partmarker'
|
||||
|
||||
# How long a PC holding a machine may go without reporting before a second PC
|
||||
# claiming that machine is treated as its replacement. A swap resolves itself
|
||||
# within a day; a PC off overnight or behind a network outage keeps its bay.
|
||||
@@ -338,7 +342,18 @@ class ComputersPlugin(BasePlugin):
|
||||
# Remote-access protocol sync (only when the payload carried the key).
|
||||
accessprotocols = self._sync_access_protocols(comp, payload, warnings)
|
||||
|
||||
# Part-marker PCs get a marker asset of their own, which is what the
|
||||
# machine number then hangs off. Done BEFORE the machine link because a
|
||||
# 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.
|
||||
partmarkers = self._sync_partmarker(comp, pctype, machinenumber,
|
||||
warnings)
|
||||
|
||||
# PC -> machine link from the reported machine number.
|
||||
if partmarkers:
|
||||
machinelinks = []
|
||||
else:
|
||||
machinelinks = self._sync_machine_link(comp, machinenumber, warnings)
|
||||
|
||||
# Printer relationship sync (only when the payload carried printer data).
|
||||
@@ -362,6 +377,7 @@ class ComputersPlugin(BasePlugin):
|
||||
'measuringtoollinkcount': len(measuringtoollinks),
|
||||
'accessprotocols': accessprotocols,
|
||||
'machinelinks': machinelinks,
|
||||
'partmarkers': partmarkers,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -687,6 +703,159 @@ class ComputersPlugin(BasePlugin):
|
||||
'machinenumber': machine.assetnumber,
|
||||
'superseded': len(held)}]
|
||||
|
||||
def _sync_partmarker(self, comp, pctype, machinenumber, warnings):
|
||||
"""Give a part-marker PC a marker asset of its own, under its operation.
|
||||
|
||||
Several Telesis markers serve one operation number - 0613, 0615 and
|
||||
WJPRT each have more than one - so treating the operation as the marker
|
||||
collapsed separate devices into one record. Their configs, which differ
|
||||
by COM port, then overwrote each other in the backup history, and no
|
||||
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.
|
||||
|
||||
That last rail is why the PC does not also claim the operation directly:
|
||||
`controls` propagates through `partof` (seeded in reference-data), so
|
||||
control of the operation follows from controlling its marker, and two
|
||||
markers on one operation no longer contest a link that can only have one
|
||||
holder.
|
||||
|
||||
Returns [] for any PC that does not drive a marker, which leaves the
|
||||
ordinary machine link to run.
|
||||
"""
|
||||
from shopdb.api import AssetRelationship, RelationshipType, Asset
|
||||
from .pctypemap import drives_partmarker, PARTMARKER_TYPENAME
|
||||
|
||||
if not drives_partmarker(pctype) or not comp or not comp.asset:
|
||||
return []
|
||||
|
||||
pcasset = comp.asset
|
||||
controls = RelationshipType.query.filter_by(
|
||||
relationshiptype='controls').first()
|
||||
if not controls:
|
||||
warnings.append("'controls' relationship type missing; "
|
||||
'run flask seed reference-data')
|
||||
return []
|
||||
|
||||
try:
|
||||
from plugins.machines.models import Machine, MachineType
|
||||
except ImportError:
|
||||
warnings.append('machines plugin unavailable; part marker skipped')
|
||||
return []
|
||||
|
||||
# Reuse this PC's existing marker before minting one, so a re-image
|
||||
# never leaves a second marker behind for the same physical device.
|
||||
existing = AssetRelationship.query.filter(
|
||||
AssetRelationship.sourceassetid == pcasset.assetid,
|
||||
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
|
||||
AssetRelationship.label == PARTMARKER_LINK_ORIGIN,
|
||||
).all()
|
||||
reuse = next((rel for rel in existing if rel.isactive), None) \
|
||||
or (existing[0] if existing else None)
|
||||
|
||||
if reuse:
|
||||
reuse.isactive = True
|
||||
markerasset = db.session.get(Asset, reuse.targetassetid)
|
||||
else:
|
||||
machinetype = AssetType.query.filter_by(assettype='machine').first()
|
||||
if not machinetype:
|
||||
warnings.append('machine asset type missing; part marker '
|
||||
'skipped')
|
||||
return []
|
||||
markertype = MachineType.query.filter_by(
|
||||
machinetype=PARTMARKER_TYPENAME).first()
|
||||
if not markertype:
|
||||
markertype = MachineType(machinetype=PARTMARKER_TYPENAME,
|
||||
description='Telesis part marker')
|
||||
db.session.add(markertype)
|
||||
db.session.flush()
|
||||
|
||||
hostname = comp.hostname
|
||||
markerasset = Asset(
|
||||
assetnumber='{}-PARTMARKER'.format(
|
||||
pcasset.assetnumber or hostname),
|
||||
name='Part Marker ({})'.format(hostname),
|
||||
assettypeid=machinetype.assettypeid,
|
||||
statusid=1)
|
||||
db.session.add(markerasset)
|
||||
db.session.flush()
|
||||
db.session.add(Machine(assetid=markerasset.assetid,
|
||||
machinetypeid=markertype.machinetypeid))
|
||||
db.session.add(AssetRelationship(
|
||||
sourceassetid=pcasset.assetid,
|
||||
targetassetid=markerasset.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid,
|
||||
label=PARTMARKER_LINK_ORIGIN,
|
||||
isactive=True))
|
||||
|
||||
# One marker per PC: archive any other collector marker link.
|
||||
for rel in existing:
|
||||
if rel is not reuse and rel.isactive:
|
||||
rel.isactive = False
|
||||
|
||||
operation = self._link_marker_to_operation(
|
||||
markerasset, machinenumber, warnings)
|
||||
|
||||
return [{'assetid': markerasset.assetid,
|
||||
'assetnumber': markerasset.assetnumber,
|
||||
'operationassetid': operation}]
|
||||
|
||||
def _link_marker_to_operation(self, markerasset, machinenumber, warnings):
|
||||
"""Make a marker `partof` the operation whose number its PC reports.
|
||||
|
||||
Unlike the PC-to-machine link this does NOT contest: an operation can
|
||||
hold any number of markers, which is the whole point. Moving a marker to
|
||||
another operation archives the old membership rather than deleting it,
|
||||
so where a marker used to live stays answerable.
|
||||
"""
|
||||
from shopdb.api import AssetRelationship, RelationshipType, Asset
|
||||
|
||||
if not machinenumber:
|
||||
return None
|
||||
|
||||
partof = RelationshipType.query.filter_by(
|
||||
relationshiptype='partof').first()
|
||||
if not partof:
|
||||
warnings.append("'partof' relationship type missing; "
|
||||
'run flask seed reference-data')
|
||||
return None
|
||||
|
||||
operation = Asset.query.filter(
|
||||
Asset.assetnumber.ilike(machinenumber),
|
||||
Asset.isactive.is_(True)).first()
|
||||
if not operation:
|
||||
warnings.append(
|
||||
'no asset for machine number {!r}; marker not filed under an '
|
||||
'operation'.format(machinenumber))
|
||||
return None
|
||||
if operation.assetid == markerasset.assetid:
|
||||
return None
|
||||
|
||||
links = AssetRelationship.query.filter(
|
||||
AssetRelationship.sourceassetid == markerasset.assetid,
|
||||
AssetRelationship.relationshiptypeid == partof.relationshiptypeid,
|
||||
AssetRelationship.label == PARTMARKER_LINK_ORIGIN,
|
||||
).all()
|
||||
found = None
|
||||
for rel in links:
|
||||
if rel.targetassetid == operation.assetid:
|
||||
rel.isactive = True
|
||||
found = rel
|
||||
else:
|
||||
rel.isactive = False
|
||||
if found is None:
|
||||
db.session.add(AssetRelationship(
|
||||
sourceassetid=markerasset.assetid,
|
||||
targetassetid=operation.assetid,
|
||||
relationshiptypeid=partof.relationshiptypeid,
|
||||
label=PARTMARKER_LINK_ORIGIN,
|
||||
isactive=True))
|
||||
return operation.assetid
|
||||
|
||||
def _assetname(self, assetid):
|
||||
"""Readable name for an asset id, for warnings and alerts."""
|
||||
from shopdb.api import Asset
|
||||
|
||||
@@ -891,3 +891,120 @@ def test_machinelink_alerts_are_off_by_default(client, db, collector_key,
|
||||
assert alerts == []
|
||||
assert len(_links(machine_3015.assetid)) == 1
|
||||
assert any('taken over' in w for w in r.get_json()['data']['warnings'])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Part markers: a marker is its own asset, under the operation it serves
|
||||
# =============================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def operation_0615(db, machine_3015):
|
||||
"""An operation number that more than one part marker serves.
|
||||
|
||||
machine_3015 is depended on for the 'machine' asset type, the 'controls'
|
||||
relationship type and 'partof', which the marker links need.
|
||||
"""
|
||||
from shopdb.core.models import AssetType, Asset, RelationshipType
|
||||
|
||||
db.session.add(RelationshipType(relationshiptype='partof',
|
||||
isdirectional=True))
|
||||
mt = AssetType.query.filter_by(assettype='machine').first()
|
||||
asset = Asset(assetnumber='0615', name='Operation 0615',
|
||||
assettypeid=mt.assettypeid, statusid=1)
|
||||
db.session.add(asset)
|
||||
db.session.commit()
|
||||
return asset
|
||||
|
||||
|
||||
def _marker(hostname):
|
||||
return {'hostname': hostname, 'machinenumber': '0615',
|
||||
'pctype': 'gea-shopfloor-partmarker'}
|
||||
|
||||
|
||||
def test_a_marker_pc_gets_a_marker_asset_of_its_own(
|
||||
client, db, collector_key, computer_assettype, operation_0615):
|
||||
from shopdb.core.models import Asset
|
||||
client.post('/api/collector/computers', json=_marker('MARKERPC1'),
|
||||
headers={'X-API-Key': collector_key})
|
||||
|
||||
marker = Asset.query.filter_by(assetnumber='MARKERPC1-PARTMARKER').first()
|
||||
assert marker is not None
|
||||
assert marker.machine is not None
|
||||
assert marker.machine.machinetype.machinetype == 'Part Marker'
|
||||
|
||||
|
||||
def test_two_markers_share_an_operation_without_contesting_it(
|
||||
client, db, collector_key, computer_assettype, operation_0615):
|
||||
"""The reason markers became assets. Both markers serve 0615, and an
|
||||
operation holds any number of them, so neither takes the other's place."""
|
||||
from shopdb.core.models import Asset, AssetRelationship, RelationshipType
|
||||
for host in ('MARKERPC1', 'MARKERPC2'):
|
||||
client.post('/api/collector/computers', json=_marker(host),
|
||||
headers={'X-API-Key': collector_key})
|
||||
|
||||
partof = RelationshipType.query.filter_by(relationshiptype='partof').first()
|
||||
members = AssetRelationship.query.filter_by(
|
||||
targetassetid=operation_0615.assetid,
|
||||
relationshiptypeid=partof.relationshiptypeid,
|
||||
isactive=True).all()
|
||||
assert len(members) == 2
|
||||
|
||||
names = sorted(db.session.get(Asset, rel.sourceassetid).assetnumber
|
||||
for rel in members)
|
||||
assert names == ['MARKERPC1-PARTMARKER', 'MARKERPC2-PARTMARKER']
|
||||
|
||||
|
||||
def test_a_marker_pc_does_not_claim_the_operation_directly(
|
||||
client, db, collector_key, computer_assettype, operation_0615):
|
||||
"""Control of the operation follows the partof rail from the marker. A
|
||||
direct PC claim would be the link two markers cannot both hold."""
|
||||
from shopdb.core.models import AssetRelationship
|
||||
client.post('/api/collector/computers', json=_marker('MARKERPC1'),
|
||||
headers={'X-API-Key': collector_key})
|
||||
|
||||
direct = AssetRelationship.query.filter_by(
|
||||
targetassetid=operation_0615.assetid,
|
||||
label='collector:machine').all()
|
||||
assert direct == []
|
||||
|
||||
|
||||
def test_reporting_again_reuses_the_marker(
|
||||
client, db, collector_key, computer_assettype, operation_0615):
|
||||
"""GE-Enforce posts every cycle; a marker must not be minted each time."""
|
||||
from shopdb.core.models import Asset
|
||||
for _ in range(3):
|
||||
client.post('/api/collector/computers', json=_marker('MARKERPC1'),
|
||||
headers={'X-API-Key': collector_key})
|
||||
markers = Asset.query.filter(
|
||||
Asset.assetnumber.like('%-PARTMARKER')).all()
|
||||
assert len(markers) == 1
|
||||
|
||||
|
||||
def test_moving_a_marker_archives_the_old_operation(
|
||||
client, db, collector_key, computer_assettype, operation_0615,
|
||||
machine_3015):
|
||||
"""Where a marker used to live stays answerable."""
|
||||
from shopdb.core.models import AssetRelationship, RelationshipType
|
||||
client.post('/api/collector/computers', json=_marker('MARKERPC1'),
|
||||
headers={'X-API-Key': collector_key})
|
||||
moved = dict(_marker('MARKERPC1'), machinenumber='3015')
|
||||
client.post('/api/collector/computers', json=moved,
|
||||
headers={'X-API-Key': collector_key})
|
||||
|
||||
partof = RelationshipType.query.filter_by(relationshiptype='partof').first()
|
||||
rows = AssetRelationship.query.filter_by(
|
||||
relationshiptypeid=partof.relationshiptypeid).all()
|
||||
active = [r for r in rows if r.isactive]
|
||||
assert len(rows) == 2, 'the old membership must be kept, not deleted'
|
||||
assert len(active) == 1
|
||||
assert active[0].targetassetid == machine_3015.assetid
|
||||
|
||||
|
||||
def test_an_ordinary_pc_gets_no_marker(
|
||||
client, db, collector_key, computer_assettype, operation_0615):
|
||||
from shopdb.core.models import Asset
|
||||
client.post('/api/collector/computers',
|
||||
json={'hostname': 'PLAINPC', 'machinenumber': '0615',
|
||||
'pctype': 'gea-shopfloor-collections'},
|
||||
headers={'X-API-Key': collector_key})
|
||||
assert Asset.query.filter(Asset.assetnumber.like('%-PARTMARKER')).all() == []
|
||||
|
||||
Reference in New Issue
Block a user