diff --git a/CHANGELOG.md b/CHANGELOG.md index 7edc144..7058323 100644 --- a/CHANGELOG.md +++ b/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`. diff --git a/plugins/backups/services/registry.py b/plugins/backups/services/registry.py index 0354fcc..e0fdd32 100644 --- a/plugins/backups/services/registry.py +++ b/plugins/backups/services/registry.py @@ -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: diff --git a/plugins/computers/pctypemap.py b/plugins/computers/pctypemap.py index 320c3dc..94e74f4 100644 --- a/plugins/computers/pctypemap.py +++ b/plugins/computers/pctypemap.py @@ -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.""" diff --git a/plugins/computers/plugin.py b/plugins/computers/plugin.py index 69cc4e6..cb1fd19 100644 --- a/plugins/computers/plugin.py +++ b/plugins/computers/plugin.py @@ -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,8 +342,19 @@ 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. - machinelinks = self._sync_machine_link(comp, machinenumber, warnings) + if partmarkers: + machinelinks = [] + else: + machinelinks = self._sync_machine_link(comp, machinenumber, warnings) # Printer relationship sync (only when the payload carried printer data). printerlinks = self._sync_printer_links(comp.asset, payload, warnings) @@ -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 diff --git a/tests/test_core/test_collector_contract.py b/tests/test_core/test_collector_contract.py index 9824d97..ed27ba6 100644 --- a/tests/test_core/test_collector_contract.py +++ b/tests/test_core/test_collector_contract.py @@ -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() == []