Merge wilm: endpoints plugin, agent, policies, CSV import, Entra fields

Thirteen commits from benjamin.baker plus the review fixes. Everything he built
is here: the endpoints plugin and its command queue, the .NET endpoint agent,
the computers policies subsystem, the site-asset CSV import with its adapters
and collector, Entra device identity, and the catalog hand-merge.

His branch predates four commits on main (section ticking, the generator share
links, Secure Send copy message, and the smoke-test workflow), so those come
through untouched - nothing of ours is removed by this merge.

wilm stays as its own branch; this does not close it.
This commit is contained in:
2026-08-28 15:56:58 -04:00
121 changed files with 15395 additions and 562 deletions

View File

@@ -0,0 +1,239 @@
"""Seed sample endpoint telemetry for the Fleet module (DEV ONLY).
Attaches EndpointState snapshots plus a handful of curated EndpointEvents to
existing Computer assets so the fleet list, the PC-detail asset panels
(status / services / recent events), and the dashboard quiet + stability cards
have something to render before a real agent exists.
Sample rows are marked with a sentinel agentversion ("0.0.0-sample") so they can
be listed and cleared without touching real agent data.
DEV ONLY: this file lives under scripts/dev/ and must never ship in a
production image (see devnotes/live-container-and-testing-workflow.md). Do not
import it from application code.
Usage (in the API container):
docker exec shopdb-flask-wilm-api-1 python scripts/dev/seed_endpoints_sample.py
docker exec shopdb-flask-wilm-api-1 python scripts/dev/seed_endpoints_sample.py --clear
docker exec shopdb-flask-wilm-api-1 python scripts/dev/seed_endpoints_sample.py --limit 8
"""
import argparse
import json
import os
import sys
from datetime import datetime, timedelta, timezone
# Repo root on sys.path so `shopdb` and `plugins` import when run as a script.
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')))
from shopdb import create_app # noqa: E402
from shopdb.api import db # noqa: E402
SAMPLE_MARKER = '0.0.0-sample'
def _naive_utcnow():
return datetime.now(timezone.utc).replace(tzinfo=None)
def _services(*running_then_stopped):
"""Build a services snapshot. Positional pairs are (name, state)."""
return [{'name': name, 'state': state} for name, state in running_then_stopped]
def _ports(*pairs):
return [{'port': port, 'process': process} for port, process in pairs]
def _interfaces(ip, mac):
return [{'name': 'Ethernet', 'ip': ip, 'mac': mac}]
# Per-machine telemetry shapes. minutesago drives whether a machine reads as
# fresh, stale, or quiet; a couple are pushed past a plausible quiet window so
# the dashboard quiet card has rows. Everything is offline-plausible because
# there is no live agent.
SAMPLE_PROFILES = [
{
'loggedinuser': 'CORP\\jmartin',
'agentversion': SAMPLE_MARKER,
'minutesago': 3,
'uptimeseconds': 4 * 86400 + 6 * 3600,
'idleseconds': 12 * 60,
'cpupercent': 14.0,
'memorypercent': 46.0,
'services': _services(('Spooler', 'Running'), ('W32Time', 'Running'),
('WinRM', 'Running'), ('MSSQLSERVER', 'Running')),
'ports': _ports((3389, 'svchost'), (5985, 'winrm'), (445, 'System')),
'interfaces': _interfaces('10.20.4.11', '00:1A:2B:3C:4D:01'),
'events': [(4624, -8), (4672, -8), (4698, -120)],
},
{
'loggedinuser': 'CORP\\operator2',
'agentversion': SAMPLE_MARKER,
'minutesago': 9,
'uptimeseconds': 2 * 3600 + 40 * 60,
'idleseconds': 3 * 60,
'cpupercent': 61.0,
'memorypercent': 72.0,
'services': _services(('Spooler', 'Running'), ('W32Time', 'Running'),
('WinRM', 'Stopped')),
'ports': _ports((3389, 'svchost'), (445, 'System')),
'interfaces': _interfaces('10.20.4.27', '00:1A:2B:3C:4D:02'),
# Recent unexpected shutdown -> shows on the stability card.
'events': [(41, -50), (1001, -30), (4625, -12)],
},
{
'loggedinuser': None,
'agentversion': SAMPLE_MARKER,
'minutesago': 22,
'uptimeseconds': 11 * 86400,
'idleseconds': 9 * 3600,
'cpupercent': 3.0,
'memorypercent': 38.0,
'services': _services(('Spooler', 'Running'), ('W32Time', 'Running'),
('WinRM', 'Running')),
'ports': _ports((3389, 'svchost'), (5985, 'winrm')),
'interfaces': _interfaces('10.20.4.44', '00:1A:2B:3C:4D:03'),
'events': [(4624, -600), (7011, -180)],
},
{
'loggedinuser': 'CORP\\nightshift',
'agentversion': SAMPLE_MARKER,
# Quiet: no report in over a day.
'minutesago': 1500,
'uptimeseconds': 30 * 60,
'idleseconds': 30 * 60,
'cpupercent': 1.0,
'memorypercent': 29.0,
'services': _services(('Spooler', 'Stopped'), ('W32Time', 'Running')),
'ports': _ports((445, 'System'),),
'interfaces': _interfaces('10.20.4.58', '00:1A:2B:3C:4D:04'),
'events': [(55, -1600), (41, -1580)],
},
{
'loggedinuser': 'CORP\\qa_kiosk',
'agentversion': SAMPLE_MARKER,
'minutesago': 6,
'uptimeseconds': 6 * 3600,
'idleseconds': 45 * 60,
'cpupercent': 22.0,
'memorypercent': 55.0,
'services': _services(('Spooler', 'Running'), ('W32Time', 'Running'),
('WinRM', 'Running'), ('BITS', 'Running')),
'ports': _ports((3389, 'svchost'), (5985, 'winrm'), (135, 'rpc')),
'interfaces': _interfaces('10.20.4.63', '00:1A:2B:3C:4D:05'),
'events': [(4624, -20), (10016, -90), (4697, -240)],
},
]
def _clear(EndpointState, EndpointEvent):
sample_states = EndpointState.query.filter_by(agentversion=SAMPLE_MARKER).all()
asset_ids = [s.assetid for s in sample_states]
events_removed = 0
if asset_ids:
events_removed = (EndpointEvent.query
.filter(EndpointEvent.assetid.in_(asset_ids))
.delete(synchronize_session=False))
states_removed = (EndpointState.query
.filter_by(agentversion=SAMPLE_MARKER)
.delete(synchronize_session=False))
db.session.commit()
print('Cleared {} sample state(s) and {} sample event(s).'
.format(states_removed, events_removed))
def _seed(limit):
from plugins.computers.models import Computer
from plugins.endpoints.models import EndpointState, EndpointEvent
from plugins.endpoints.eventcatalog import EVENT_CATALOG
computers = (Computer.query
.filter(Computer.assetid.isnot(None))
.order_by(Computer.computerid.asc())
.limit(limit)
.all())
if not computers:
print('No Computer assets found to attach sample endpoints to. '
'Seed the computers plugin first.')
return
now = _naive_utcnow()
created, updated, events_added = 0, 0, 0
for index, computer in enumerate(computers):
profile = SAMPLE_PROFILES[index % len(SAMPLE_PROFILES)]
reported = now - timedelta(minutes=profile['minutesago'])
boot = reported - timedelta(seconds=profile['uptimeseconds'])
state = EndpointState.query.filter_by(assetid=computer.assetid).first()
if state and state.agentversion not in (None, SAMPLE_MARKER):
# Never stomp a real agent's snapshot.
continue
if not state:
state = EndpointState(assetid=computer.assetid, createdat=now)
db.session.add(state)
created += 1
else:
updated += 1
state.hostname = computer.hostname or 'HOST-{}'.format(computer.computerid)
state.loggedinuser = profile['loggedinuser']
state.agentversion = SAMPLE_MARKER
state.lastboottime = boot
state.uptimeseconds = profile['uptimeseconds']
state.idleseconds = profile['idleseconds']
state.cpupercent = profile['cpupercent']
state.memorypercent = profile['memorypercent']
state.servicesjson = json.dumps(profile['services'])
state.portsjson = json.dumps(profile['ports'])
state.interfacesjson = json.dumps(profile['interfaces'])
state.scheduledtasksjson = json.dumps([])
state.lastreportedat = reported
# Refresh this asset's sample events so re-running stays idempotent.
EndpointEvent.query.filter_by(assetid=computer.assetid).delete(
synchronize_session=False)
for eventid, offset_minutes in profile['events']:
title, severity, channel = EVENT_CATALOG.get(
eventid, ('Event {}'.format(eventid), 'info', 'System'))
event = EndpointEvent(
assetid=computer.assetid,
eventid=eventid,
eventchannel=channel,
eventsource='sample-seed',
eventtime=reported + timedelta(minutes=offset_minutes),
eventmessage='{} (sample)'.format(title),
payloadjson=json.dumps({'sample': True, 'severity': severity}),
recordedat=now,
)
db.session.add(event)
events_added += 1
db.session.commit()
print('Seeded sample endpoints: {} created, {} updated, {} event(s) added '
'across {} computer(s).'
.format(created, updated, events_added, len(computers)))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--clear', action='store_true',
help='Remove sample-marked endpoint states and their events.')
parser.add_argument('--limit', type=int, default=5,
help='Max number of computers to attach sample endpoints to.')
args = parser.parse_args()
app = create_app(os.environ.get('FLASK_ENV', 'production'))
with app.app_context():
from plugins.endpoints.models import EndpointState, EndpointEvent
if args.clear:
_clear(EndpointState, EndpointEvent)
else:
_seed(args.limit)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,102 @@
"""Seed the org's shared shopfloor applications (DEV ONLY).
Inserts the applications that ship as common across the organization so the
Configuration Policy builder has real apps to scope against and the software
inventory has canonical rows to match collected installs to. Each app records
its executable and (where relevant) its Windows service name in
applicationnotes, since the Application model has no dedicated process/service
column - these identifiers are what a generic service_*/process_* policy action
targets.
Shipped set:
* UDC - UDC.exe
* MTConnect - agent.exe process, "MTConnect UDC Agent" service
* eDNC - DncMain.exe process
Idempotent: matches on appname and updates in place, so re-running does not
duplicate. Marked installable so they appear in installable-app pickers.
DEV ONLY: lives under scripts/dev/ and must never be imported by application
code. Run inside the API container.
Usage (in the API container):
docker exec shopdb-flask-wilm-api-1 python scripts/dev/seed_shared_applications.py
docker exec shopdb-flask-wilm-api-1 python scripts/dev/seed_shared_applications.py --clear
"""
import argparse
import os
import sys
# Repo root on sys.path so `shopdb` imports when run as a script.
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')))
from shopdb import create_app # noqa: E402
from shopdb.api import db # noqa: E402
# appname -> seed fields. 'notes' captures the process image and service name so
# a generic policy action (params.target) has a canonical identifier to bind to.
SHARED_APPS = [
{
'appname': 'UDC',
'appdescription': 'Universal Data Collector',
'notes': 'process=UDC.exe',
},
{
'appname': 'MTConnect',
'appdescription': 'MTConnect UDC agent',
'notes': 'process=agent.exe; service=MTConnect UDC Agent',
},
{
'appname': 'eDNC',
'appdescription': 'eDNC distributed NC file transfer',
'notes': 'process=DncMain.exe',
},
]
def _seed(Application):
created = 0
updated = 0
for spec in SHARED_APPS:
app_row = Application.query.filter_by(appname=spec['appname']).first()
if app_row is None:
app_row = Application(appname=spec['appname'])
db.session.add(app_row)
created += 1
else:
updated += 1
app_row.appdescription = spec['appdescription']
app_row.applicationnotes = spec['notes']
app_row.isinstallable = True
db.session.commit()
print('Seeded shared applications: {} created, {} updated.'
.format(created, updated))
def _clear(Application):
names = [spec['appname'] for spec in SHARED_APPS]
removed = (Application.query
.filter(Application.appname.in_(names))
.delete(synchronize_session=False))
db.session.commit()
print('Removed {} shared application row(s).'.format(removed))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--clear', action='store_true',
help='Remove the seeded shared applications.')
args = parser.parse_args()
app = create_app(os.environ.get('FLASK_ENV', 'production'))
with app.app_context():
from shopdb.api import Application
if args.clear:
_clear(Application)
else:
_seed(Application)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,156 @@
"""Generate reconciliation SQL for the locations table.
Reads a TSV export (locationid, locationname, description, locationtypeid,
parentlocationid) and emits a single transaction-wrapped SQL script that:
- Treats bare grid coordinates ([A-V][0-9]{1,2}W? and LD[0-9]+W?) as the
canonical identity for a cell.
- Folds coordinate-plus-suffix rows into the canonical coordinate, moving the
suffix text into the canonical row's description (never the name).
- Folds named-area suffix variants ("NDE - 6409") into the parent named area
("NDE"); named areas are kept as-is and never forced into coordinates.
- Repoints assets.locationid, subnets.locationid and child
locations.parentlocationid onto the canonical row, then deletes duplicates.
Bare coordinates with no variants are left untouched. The trailing W (west
column, e.g. G2W, K3W) is part of the coordinate and is never stripped, so
G2W is kept distinct from a hypothetical G2.
"""
import re
import sys
COORD = re.compile(r'^(LD|[ABCDEFGHJKLMNPQRSTUV])(\d{1,2})(W)?(?=$|[ \-])')
def esc(s):
return s.replace("'", "''")
def load(path):
rows = []
with open(path, encoding='utf-8-sig') as fh:
for line in fh:
line = line.rstrip('\n').rstrip('\r')
if not line:
continue
parts = line.split('\t')
if len(parts) < 5:
parts += [''] * (5 - len(parts))
lid, name, desc, typeid, parentid = parts[:5]
rows.append({
'id': int(lid),
'name': name,
'desc': desc,
'typeid': typeid,
'parentid': parentid,
})
return rows
def classify(name):
"""Return (canonical, suffix). canonical is the bare coordinate or named
parent; suffix is the trailing detail (may be '')."""
m = COORD.match(name)
if m:
prefix, digits, west = m.group(1), m.group(2), m.group(3) or ''
canonical = f"{prefix}{digits}{west}"
suffix = name[m.end():].strip()
suffix = re.sub(r'^[\-\s]+', '', suffix).strip()
return canonical, suffix, 'coord'
# named area: fold on ' - ' separator only
if ' - ' in name:
base, _, rest = name.partition(' - ')
return base.strip(), rest.strip(), 'named'
return name.strip(), '', 'named'
def main(tsv, out):
rows = load(tsv)
# Build canonical groups. Key is casefolded canonical for grouping, but we
# preserve a display canonical (prefer an existing exact bare/standalone).
groups = {}
for r in rows:
canonical, suffix, kind = classify(r['name'])
key = (kind, canonical.casefold())
g = groups.setdefault(key, {
'canonical': canonical,
'kind': kind,
'members': [],
'exact_id': None,
})
r['_suffix'] = suffix
r['_canonical'] = canonical
g['members'].append(r)
# exact row: name equals canonical (no suffix)
if suffix == '' and r['name'].strip() == canonical:
g['exact_id'] = r['id']
g['canonical'] = r['name'].strip()
lines = []
lines.append('-- Auto-generated locations reconciliation. Review before running.')
lines.append('START TRANSACTION;')
touched = 0
for key, g in sorted(groups.items(), key=lambda kv: kv[0][1]):
members = sorted(g['members'], key=lambda r: r['id'])
# nothing to do: single member that is already the exact canonical
if len(members) == 1 and g['exact_id'] == members[0]['id']:
continue
touched += 1
canonical = g['canonical']
# choose target id: prefer exact bare row, else promote smallest id
if g['exact_id'] is not None:
target = g['exact_id']
promote = None
else:
target = members[0]['id']
promote = members[0]
# accumulate suffix/description text from all members (dedup, ordered)
details = []
for m in members:
for chunk in (m['_suffix'], m['desc']):
chunk = (chunk or '').strip()
if chunk and chunk not in details:
details.append(chunk)
desc_val = '; '.join(details)
lines.append('')
lines.append(f"-- {canonical} (target locationid {target}) <= "
+ ', '.join(str(m['id']) for m in members))
if promote is not None:
lines.append(
f"UPDATE locations SET locationname='{esc(canonical)}' "
f"WHERE locationid={target};")
if desc_val:
lines.append(
f"UPDATE locations SET description='{esc(desc_val)}' "
f"WHERE locationid={target};")
others = [m['id'] for m in members if m['id'] != target]
if others:
ids = ','.join(str(i) for i in others)
lines.append(
f"UPDATE assets SET locationid={target} "
f"WHERE locationid IN ({ids});")
lines.append(
f"UPDATE subnets SET locationid={target} "
f"WHERE locationid IN ({ids});")
lines.append(
f"UPDATE locations SET parentlocationid={target} "
f"WHERE parentlocationid IN ({ids});")
lines.append(
f"DELETE FROM locations WHERE locationid IN ({ids});")
lines.append('')
lines.append('COMMIT;')
lines.append(f'-- groups touched: {touched}')
with open(out, 'w', encoding='utf-8') as fh:
fh.write('\n'.join(lines) + '\n')
print(f'wrote {out}: {touched} groups touched, {len(rows)} source rows')
if __name__ == '__main__':
main(sys.argv[1], sys.argv[2])

View File

@@ -0,0 +1,594 @@
#Requires -Version 5.1
[CmdletBinding()]
param(
# Where CSVs are collected/accumulated. Overrides the in-script CONFIG block
# below. See the CONFIG block for the easy place to set this permanently.
[Parameter(Mandatory = $false)]
[string]$OutputRoot,
# Optional network share to mirror the store into after a run. Overrides the
# in-script CONFIG block below.
[Parameter(Mandatory = $false)]
[string]$PublishRoot,
# Business identifier for this PC (workstation number). Defaults to hostname.
[Parameter(Mandatory = $false)]
[string]$AssetNumber,
# Value written into assets.assettypeid for the PC (resolved by name).
[Parameter(Mandatory = $false)]
[string]$AssetType = 'Computer',
# Value written into computers.computertypeid (resolved by name).
[Parameter(Mandatory = $false)]
[string]$ComputerType,
# Value written into machines.machinetypeid (resolved by name).
[Parameter(Mandatory = $false)]
[string]$MachineType,
# Explicit eDNC/UDC source overrides. When omitted, standard on-asset
# locations are searched.
[Parameter(Mandatory = $false)]
[string]$RegPath,
[Parameter(Mandatory = $false)]
[string]$JsonPath,
# Skip switches for partial runs.
[switch]$SkipMachine,
[switch]$SkipApps
)
<#
.SYNOPSIS
Unified ShopDB site-adoption collector. Runs on any PC, collects the PC and any
attached eDNC/DNC machine, and UPSERTS the results into a shared set of
importable CSVs so a site can build its inventory PC by PC.
.DESCRIPTION
Accumulating store under OutputRoot:
vendors.csv, models.csv, operatingsystems.csv, assets.csv,
computers.csv, machines.csv
Rows are merged field-by-field on each table's natural key (a later run fills
gaps and refreshes values without wiping data an earlier run captured).
Phase-2 data that CSV cannot carry is written per entity under OutputRoot\sidecars:
computer_<assetnumber>.json (interfaces, installed apps)
machine_<machinenumber>.json (PC<->Machine link, connection detail)
Read-only against the machine. Local reads may run elevated; publishing to a
share runs in the current user context only.
.NOTES
PS 5.1 compatible. Headers match the ShopDB importer (see CSV-SCHEMA.md).
#>
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# =============================================================================
# CONFIG - SET YOUR CSV DESTINATIONS HERE
# -----------------------------------------------------------------------------
# Two-stage flow so you can collect locally (possibly elevated) and then append
# the captured rows to a CSV store that lives on a network share:
#
# 1) LocalStageRoot Fast, always-writable local folder where CSVs are built
# and merged. Collection can run elevated here.
# 2) NetworkStoreRoot The shared CSV store the whole site accumulates into and
# that you later point 'flask csv import --dir' at. Leave
# blank ('') to keep everything local. When set, the merged
# store is copied here at the end of the run. Network
# writes must happen as a STANDARD user - do NOT run the
# publish step elevated.
#
# Example network path: '\\fileserver\shopdb$\intake'
#
# Command-line -OutputRoot / -PublishRoot still override these values.
# =============================================================================
$LocalStageRoot = Join-Path $env:ProgramData 'ShopDB\intake'
# Ship blank so a site that has not configured its share keeps everything local
# instead of publishing to another site's path. Set this to your own share (or
# pass -PublishRoot), e.g. '\\fileserver\shopdb$\intake'. The download endpoint
# fills this in from asset_import_csv_location when the site has set it.
$NetworkStoreRoot = ''
# Resolve effective destinations (command-line params win over CONFIG above).
if (-not $OutputRoot) { $OutputRoot = $LocalStageRoot }
if (-not $PublishRoot) { $PublishRoot = $NetworkStoreRoot }
# --- Store definitions (header order + natural key per importable table) -------
$script:Stores = [ordered]@{
'vendors.csv' = @{ key = 'vendor'; header = @('vendor') }
'models.csv' = @{ key = 'modelnumber'; header = @('modelnumber','vendorid','modeltypeid') }
'operatingsystems.csv' = @{ key = 'osname'; header = @('osname') }
'assets.csv' = @{ key = 'assetnumber'; header = @('assetnumber','assettypeid','name','statusid','serialnumber','gaugelabreference','maintenancereference','locationid','businessunitid','notes') }
'computers.csv' = @{ key = 'assetid'; header = @('assetid','hostname','computertypeid','osid','vendorid','modelnumberid','loggedinuser','lastreporteddate','lastboottime') }
'machines.csv' = @{ key = 'assetid'; header = @('assetid','machinetypeid','vendorid','modelnumberid','controllervendorid','controllermodelid','requiresmanualconfig','islocationonly','notes') }
}
# --- Shared helpers -----------------------------------------------------------
function ConvertTo-CsvField {
param([AllowNull()]$Value)
if ($null -eq $Value) { return '' }
$text = [string]$Value
if ($text -match '[",\r\n]') { return '"' + ($text -replace '"', '""') + '"' }
return $text
}
function Get-Prop {
param([AllowNull()]$Object, [Parameter(Mandatory = $true)][string]$Name)
if ($null -eq $Object) { return $null }
$prop = $Object.PSObject.Properties[$Name]
if ($null -eq $prop) { return $null }
return $prop.Value
}
function Get-NonEmpty {
param([AllowNull()]$Value)
if ($null -ne $Value -and -not [string]::IsNullOrWhiteSpace([string]$Value)) {
return ([string]$Value).Trim()
}
return $null
}
function Get-FirstNonEmpty {
param([object[]]$Values)
foreach ($v in $Values) { $n = Get-NonEmpty $v; if ($n) { return $n } }
return $null
}
function Format-ShopDbDate {
param([AllowNull()]$Value)
if ($null -eq $Value) { return $null }
try { return ([datetime]$Value).ToString('yyyy-MM-dd HH:mm:ss') } catch { return $null }
}
function Write-Utf8NoBom {
param([string]$Path, [string]$Content)
$encoding = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($Path, $Content, $encoding)
}
# Field-level upsert into a persistent CSV store, keyed by natural key. Existing
# values are kept when the incoming cell is empty, so a partial run never blanks
# out data an earlier run captured.
function Merge-CsvStore {
param(
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)][string[]]$Header,
[Parameter(Mandatory = $true)][string]$Key,
[Parameter(Mandatory = $true)][AllowNull()][object[]]$IncomingRows
)
$index = [ordered]@{}
if (Test-Path -LiteralPath $Path) {
foreach ($existing in @(Import-Csv -LiteralPath $Path)) {
$keyVal = Get-NonEmpty (Get-Prop $existing $Key)
if (-not $keyVal) { continue }
$index[$keyVal.ToLowerInvariant()] = $existing
}
}
$changed = 0
foreach ($row in $IncomingRows) {
if ($null -eq $row) { continue }
$keyVal = Get-NonEmpty (Get-Prop $row $Key)
if (-not $keyVal) { continue }
$lc = $keyVal.ToLowerInvariant()
if ($index.Contains($lc)) {
$target = $index[$lc]
foreach ($col in $Header) {
$incoming = Get-NonEmpty (Get-Prop $row $col)
if ($incoming) {
if ($target.PSObject.Properties[$col]) { $target.$col = $incoming }
else { $target | Add-Member -NotePropertyName $col -NotePropertyValue $incoming -Force }
}
}
} else {
$fresh = [ordered]@{}
foreach ($col in $Header) { $fresh[$col] = Get-NonEmpty (Get-Prop $row $col) }
$index[$lc] = [pscustomobject]$fresh
}
$changed++
}
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add(($Header -join ','))
foreach ($stored in $index.Values) {
$cells = foreach ($col in $Header) { ConvertTo-CsvField (Get-Prop $stored $col) }
$lines.Add(($cells -join ','))
}
Write-Utf8NoBom -Path $Path -Content (($lines -join "`r`n") + "`r`n")
return $changed
}
# --- eDNC/UDC parsing (registry, .reg export, UDC settings json) --------------
function Resolve-ControllerVendor {
param([AllowNull()][string]$ControlType, [AllowNull()][string]$NcIF)
$ct = ('' + $ControlType).Trim().ToUpperInvariant()
$nc = ('' + $NcIF).Trim().ToUpperInvariant()
if ($nc -eq 'EFOCAS' -or $ct -eq 'EFOCAS') { return 'FANUC' }
if ($ct -eq 'SIEMENS') { return 'Siemens' }
if ($ct -eq 'OKUMA') { return 'Okuma' }
return $null
}
function Read-RegExport {
param([Parameter(Mandatory = $true)][string]$Path)
$lines = @(Get-Content -LiteralPath $Path -Encoding Unicode -ErrorAction Stop)
$firstMeaningful = ($lines | Where-Object { $_ -match '\S' } | Select-Object -First 1)
if (-not ($firstMeaningful -match '^(REGEDIT4|Windows Registry Editor Version)')) {
$lines = @(Get-Content -LiteralPath $Path -Encoding Default -ErrorAction Stop)
}
$data = @{}
$currentKey = $null
foreach ($raw in $lines) {
$line = $raw.Trim()
if ([string]::IsNullOrWhiteSpace($line)) { continue }
if ($line -match '^(REGEDIT4|Windows Registry Editor Version)') { continue }
if ($line -match '^\s*;') { continue }
if ($line -match '^\[(?<key>[^\]]+)\]$') {
$currentKey = $Matches['key']
if (-not $data.ContainsKey($currentKey)) { $data[$currentKey] = @{} }
continue
}
if (-not $currentKey) { continue }
if ($line -match '^"(?<name>[^"]+)"=(?<rhs>.+)$') {
$name = $Matches['name']
$rhs = $Matches['rhs'].Trim()
if ($rhs -match '^"(?<s>.*)"$') { $val = $Matches['s'] }
elseif ($rhs -match '^dword:(?<hex>[0-9a-fA-F]{8})$') { $val = [Convert]::ToInt32($Matches['hex'], 16) }
else { $val = $rhs }
$data[$currentKey][$name] = $val
}
}
return $data
}
function Get-RegExportValue {
param([hashtable]$Reg, [string]$KeySuffix, [string]$Name)
foreach ($key in $Reg.Keys) {
if ($key -match ('\\' + [regex]::Escape($KeySuffix) + '$')) {
if ($Reg[$key].ContainsKey($Name)) { return $Reg[$key][$Name] }
}
}
return $null
}
function ConvertFrom-RegExport {
param([hashtable]$Reg)
[ordered]@{
source = 'reg'
machinenumber = Get-NonEmpty (Get-RegExportValue $Reg 'General' 'MachineNo')
site = Get-NonEmpty (Get-RegExportValue $Reg 'General' 'Site')
controltype = Get-NonEmpty (Get-RegExportValue $Reg 'General' 'Cnc')
ncif = Get-NonEmpty (Get-RegExportValue $Reg 'General' 'NcIF')
dnctype = 'eDNC'
tcpaddress = Get-NonEmpty (Get-RegExportValue $Reg 'eFocas' 'IpAddr')
tcpport = Get-NonEmpty (Get-RegExportValue $Reg 'eFocas' 'SocketNo')
comport = Get-NonEmpty (Get-RegExportValue $Reg 'PPDCS' 'Port Id')
baudrate = Get-NonEmpty (Get-RegExportValue $Reg 'PPDCS' 'Baud')
}
}
function Get-RegistryValue {
param([string]$Path, [string]$Name)
if (-not (Test-Path -LiteralPath $Path)) { return $null }
return Get-Prop (Get-ItemProperty -LiteralPath $Path -ErrorAction SilentlyContinue) $Name
}
function ConvertFrom-LiveRegistry {
param([string]$Base)
if (-not (Test-Path -LiteralPath $Base)) { return $null }
$general = Join-Path $Base 'General'
$efocas = Join-Path $Base 'eFocas'
$ppdcs = Join-Path $Base 'PPDCS'
[ordered]@{
source = 'registry'
machinenumber = Get-NonEmpty (Get-RegistryValue $general 'MachineNo')
site = Get-NonEmpty (Get-RegistryValue $general 'Site')
controltype = Get-NonEmpty (Get-RegistryValue $general 'Cnc')
ncif = Get-NonEmpty (Get-RegistryValue $general 'NcIF')
dnctype = 'eDNC'
tcpaddress = Get-NonEmpty (Get-RegistryValue $efocas 'IpAddr')
tcpport = Get-NonEmpty (Get-RegistryValue $efocas 'SocketNo')
comport = Get-NonEmpty (Get-RegistryValue $ppdcs 'Port Id')
baudrate = Get-NonEmpty (Get-RegistryValue $ppdcs 'Baud')
}
}
function ConvertFrom-UdcJson {
param([string]$Path)
$obj = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop | ConvertFrom-Json
$general = Get-Prop $obj 'GeneralSettings'
$ethernet = Get-Prop $obj 'EthernetSettings'
$serial = Get-Prop $obj 'SerialSettings'
[ordered]@{
source = 'json'
machinenumber = Get-NonEmpty (Get-Prop $general 'MachineNumber')
site = Get-NonEmpty (Get-Prop $general 'Site')
controltype = Get-NonEmpty (Get-Prop $general 'ControlType')
ncif = $null
dnctype = Get-NonEmpty (Get-Prop $general 'DncType')
tcpaddress = Get-NonEmpty (Get-Prop $ethernet 'TCPAddress')
tcpport = Get-NonEmpty (Get-Prop $ethernet 'TCPPort')
comport = Get-NonEmpty (Get-Prop $serial 'ComPort')
baudrate = Get-NonEmpty (Get-Prop $serial 'BaudRate')
}
}
function Find-FirstFile {
param([string[]]$Roots, [string]$Filter)
foreach ($root in $Roots) {
if (-not $root -or -not (Test-Path -LiteralPath $root)) { continue }
$hit = Get-ChildItem -LiteralPath $root -Filter $Filter -File -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending | Select-Object -First 1
if ($hit) { return $hit.FullName }
}
return $null
}
function Resolve-DncSource {
param([string]$RegPath, [string]$JsonPath)
if ($JsonPath -and (Test-Path -LiteralPath $JsonPath)) {
Write-Host (' machine source: JSON (explicit) ' + $JsonPath) -ForegroundColor DarkGray
return ConvertFrom-UdcJson -Path $JsonPath
}
if ($RegPath -and (Test-Path -LiteralPath $RegPath)) {
Write-Host (' machine source: .reg (explicit) ' + $RegPath) -ForegroundColor DarkGray
return ConvertFrom-RegExport -Reg (Read-RegExport -Path $RegPath)
}
foreach ($base in @(
'HKLM:\SOFTWARE\GE Aircraft Engines\DNC',
'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC')) {
$fromReg = ConvertFrom-LiveRegistry -Base $base
if ($fromReg -and $fromReg.machinenumber) {
Write-Host (' machine source: live registry ' + $base) -ForegroundColor DarkGray
return $fromReg
}
}
$localReg = Find-FirstFile -Roots @('C:\Program Files (x86)\Dnc', (Join-Path $env:SystemDrive 'GE Aerospace')) -Filter '*.reg'
if ($localReg) {
Write-Host (' machine source: local .reg ' + $localReg) -ForegroundColor DarkGray
return ConvertFrom-RegExport -Reg (Read-RegExport -Path $localReg)
}
$localJson = Find-FirstFile -Roots @((Join-Path $env:SystemDrive 'GE Aerospace'), 'C:\Program Files (x86)\UDC', 'C:\Program Files (x86)\Dnc') -Filter 'udc_settings_*.json'
if ($localJson) {
Write-Host (' machine source: local UDC json ' + $localJson) -ForegroundColor DarkGray
return ConvertFrom-UdcJson -Path $localJson
}
return $null
}
# --- PC collection (read-only) ------------------------------------------------
function Get-PcFacts {
$computerSystem = Get-CimInstance Win32_ComputerSystem -ErrorAction SilentlyContinue
$operatingSys = Get-CimInstance Win32_OperatingSystem -ErrorAction SilentlyContinue
$bios = Get-CimInstance Win32_BIOS -ErrorAction SilentlyContinue
$interfaces = @()
try {
foreach ($cfg in @(Get-NetIPConfiguration -ErrorAction SilentlyContinue)) {
$adapter = $null
try { $adapter = Get-NetAdapter -InterfaceAlias $cfg.InterfaceAlias -ErrorAction SilentlyContinue } catch {}
if (-not $adapter -or (Get-Prop $adapter 'HardwareInterface') -ne $true) { continue }
$ipv4 = @(Get-Prop $cfg 'IPv4Address') | Select-Object -First 1
$interfaces += [ordered]@{
interfacealias = [string]$cfg.InterfaceAlias
ipaddress = if ($ipv4) { [string](Get-Prop $ipv4 'IPAddress') } else { $null }
macaddress = [string](Get-Prop $adapter 'MacAddress')
}
}
} catch {}
$installedApps = @()
if (-not $SkipApps) {
foreach ($keyPath in @(
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*')) {
foreach ($entry in @(Get-ItemProperty -Path $keyPath -ErrorAction SilentlyContinue)) {
$displayName = Get-NonEmpty (Get-Prop $entry 'DisplayName')
if (-not $displayName) { continue }
if ((Get-Prop $entry 'SystemComponent') -eq 1) { continue }
$installedApps += [ordered]@{
appname = $displayName
installedversion = Get-NonEmpty (Get-Prop $entry 'DisplayVersion')
publisher = Get-NonEmpty (Get-Prop $entry 'Publisher')
}
}
}
$installedApps = @($installedApps | Sort-Object { $_.appname } -Unique)
}
[ordered]@{
hostname = Get-FirstNonEmpty @($env:COMPUTERNAME, (Get-Prop $computerSystem 'Name'))
vendor = Get-NonEmpty (Get-Prop $computerSystem 'Manufacturer')
model = Get-NonEmpty (Get-Prop $computerSystem 'Model')
osname = Get-NonEmpty (Get-Prop $operatingSys 'Caption')
serial = Get-FirstNonEmpty @((Get-Prop $bios 'SerialNumber'), (Get-Prop $computerSystem 'SerialNumber'))
lastboot = Format-ShopDbDate (Get-Prop $operatingSys 'LastBootUpTime')
loggeduser = Get-NonEmpty (Get-Prop $computerSystem 'UserName')
interfaces = $interfaces
installedapps = $installedApps
}
}
# --- Main ---------------------------------------------------------------------
Write-Host ''
Write-Host ' ShopDB unified collector' -ForegroundColor Cyan
Write-Host (' ' + ('-' * 56)) -ForegroundColor DarkGray
if (-not (Test-Path -LiteralPath $OutputRoot)) { New-Item -ItemType Directory -Path $OutputRoot -Force | Out-Null }
$sidecarDir = Join-Path $OutputRoot 'sidecars'
if (-not (Test-Path -LiteralPath $sidecarDir)) { New-Item -ItemType Directory -Path $sidecarDir -Force | Out-Null }
$collectedAt = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')
$pc = Get-PcFacts
if (-not $AssetNumber) { $AssetNumber = $pc.hostname }
# Row sets keyed to the shared stores.
$vendorRows = @()
$modelRows = @()
$osRows = @()
$assetRows = @()
$computerRows = @()
$machineRows = @()
if ($pc.vendor) { $vendorRows += [pscustomobject]@{ vendor = $pc.vendor } }
if ($pc.model) { $modelRows += [pscustomobject]@{ modelnumber = $pc.model; vendorid = $pc.vendor; modeltypeid = $null } }
if ($pc.osname) { $osRows += [pscustomobject]@{ osname = $pc.osname } }
$assetRows += [pscustomobject]@{
assetnumber = $AssetNumber; assettypeid = $AssetType; name = $pc.hostname; statusid = $null
serialnumber = $pc.serial; gaugelabreference = $null; maintenancereference = $null
locationid = $null; businessunitid = $null
notes = 'PC collected ' + $collectedAt
}
$computerRows += [pscustomobject]@{
assetid = $AssetNumber; hostname = $pc.hostname; computertypeid = $ComputerType; osid = $pc.osname
vendorid = $pc.vendor; modelnumberid = $pc.model; loggedinuser = $pc.loggeduser
lastreporteddate = $collectedAt; lastboottime = $pc.lastboot
}
# Machine (best-effort).
$machine = $null
if (-not $SkipMachine) {
try { $machine = Resolve-DncSource -RegPath $RegPath -JsonPath $JsonPath } catch { $machine = $null }
}
if ($machine -and $machine.machinenumber) {
$controllerVendor = Resolve-ControllerVendor -ControlType $machine.controltype -NcIF $machine.ncif
$transport = if ($machine.tcpaddress) { 'ethernet' } elseif ($machine.comport) { 'serial' } else { 'unknown' }
$noteBits = @("DNC=$($machine.dnctype)")
if ($machine.controltype) { $noteBits += "control=$($machine.controltype)" }
$noteBits += "transport=$transport"
$machineNote = 'eDNC collected ' + $collectedAt + '; ' + ($noteBits -join '; ')
if ($controllerVendor) { $vendorRows += [pscustomobject]@{ vendor = $controllerVendor } }
$assetRows += [pscustomobject]@{
assetnumber = $machine.machinenumber; assettypeid = 'Machine'; name = $machine.machinenumber; statusid = $null
serialnumber = $null; gaugelabreference = $null; maintenancereference = $null
locationid = $null; businessunitid = $null; notes = $machineNote
}
$machineRows += [pscustomobject]@{
assetid = $machine.machinenumber; machinetypeid = $MachineType; vendorid = $null; modelnumberid = $null
controllervendorid = $controllerVendor; controllermodelid = $null
requiresmanualconfig = $null; islocationonly = $null; notes = $machineNote
}
$link = [ordered]@{
pcassetnumber = $AssetNumber; machineassetnumber = $machine.machinenumber; relationship = 'controls'
dnctype = $machine.dnctype; controltype = $machine.controltype; controllervendor = $controllerVendor
site = $machine.site; source = $machine.source; collectedat = $collectedAt
connection = [ordered]@{
transport = $transport; tcpaddress = $machine.tcpaddress; tcpport = $machine.tcpport
comport = $machine.comport; baudrate = $machine.baudrate
}
}
Write-Utf8NoBom -Path (Join-Path $sidecarDir ('machine_' + $machine.machinenumber + '.json')) -Content ($link | ConvertTo-Json -Depth 6)
Write-Host (' linked machine ' + $machine.machinenumber + ' (' + $controllerVendor + ', ' + $transport + ')') -ForegroundColor DarkGray
} elseif (-not $SkipMachine) {
Write-Host ' no eDNC/DNC/UDC machine config found on this PC' -ForegroundColor DarkGray
}
# --- Upsert into the shared store --------------------------------------------
$rowsByStore = @{
'vendors.csv' = $vendorRows
'models.csv' = $modelRows
'operatingsystems.csv' = $osRows
'assets.csv' = $assetRows
'computers.csv' = $computerRows
'machines.csv' = $machineRows
}
Write-Host ''
Write-Host (' Store: ' + $OutputRoot) -ForegroundColor Green
foreach ($fileName in $script:Stores.Keys) {
$spec = $script:Stores[$fileName]
$rows = @($rowsByStore[$fileName])
if ($rows.Count -eq 0) { continue }
$path = Join-Path $OutputRoot $fileName
$n = Merge-CsvStore -Path $path -Header $spec.header -Key $spec.key -IncomingRows $rows
Write-Host (' merged ' + $n + ' row(s) into ' + $fileName) -ForegroundColor DarkGray
}
# PC phase-2 sidecar.
$pcSidecar = [ordered]@{
assetnumber = $AssetNumber; hostname = $pc.hostname; collectedat = $collectedAt
interfaces = $pc.interfaces; installedapps = $pc.installedapps
}
Write-Utf8NoBom -Path (Join-Path $sidecarDir ('computer_' + $AssetNumber + '.json')) -Content ($pcSidecar | ConvertTo-Json -Depth 6)
# --- Publish (standard-user network mirror, optional) -------------------------
if ($PublishRoot) {
if (([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)) {
Write-Host ''
Write-Host ' WARNING: publishing while elevated. Network writes should run as the standard user.' -ForegroundColor Yellow
}
if (-not (Test-Path -LiteralPath $PublishRoot)) { New-Item -ItemType Directory -Path $PublishRoot -Force | Out-Null }
# Merge THIS PC's rows into the shared store rather than overwriting it. A
# blind copy would erase every other PC's rows (issue: the store is meant to
# accumulate site-wide). Merge-CsvStore upserts field-by-field, so a second
# PC's run adds its rows and refreshes shared ones without dropping the rest.
# A lock file serializes concurrent publishers so two PCs cannot read-modify-
# write the same store CSV at once and lose each other's rows.
$lockPath = Join-Path $PublishRoot '.publish.lock'
$lockStream = $null
for ($attempt = 0; $attempt -lt 60; $attempt++) {
try {
$lockStream = [System.IO.File]::Open(
$lockPath, [System.IO.FileMode]::OpenOrCreate,
[System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
break
} catch {
Start-Sleep -Milliseconds 500
}
}
if ($null -eq $lockStream) {
Write-Host ''
Write-Host (' WARNING: could not acquire publish lock at ' + $lockPath + '; skipping network merge.') -ForegroundColor Yellow
} else {
try {
foreach ($fileName in $script:Stores.Keys) {
$spec = $script:Stores[$fileName]
$localPath = Join-Path $OutputRoot $fileName
if (-not (Test-Path -LiteralPath $localPath)) { continue }
$localRows = @(Import-Csv -LiteralPath $localPath)
if ($localRows.Count -eq 0) { continue }
$sharePath = Join-Path $PublishRoot $fileName
$n = Merge-CsvStore -Path $sharePath -Header $spec.header -Key $spec.key -IncomingRows $localRows
Write-Host (' merged ' + $n + ' row(s) into ' + $fileName + ' on share') -ForegroundColor DarkGray
}
# Sidecars are uniquely named per asset, so a straight copy is safe and
# nothing is overwritten across PCs.
$localSidecars = Join-Path $OutputRoot 'sidecars'
if (Test-Path -LiteralPath $localSidecars) {
$shareSidecars = Join-Path $PublishRoot 'sidecars'
if (-not (Test-Path -LiteralPath $shareSidecars)) { New-Item -ItemType Directory -Path $shareSidecars -Force | Out-Null }
Copy-Item -Path (Join-Path $localSidecars '*') -Destination $shareSidecars -Recurse -Force
}
} finally {
$lockStream.Close()
$lockStream.Dispose()
Remove-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue
}
Write-Host ''
Write-Host (' Published store to: ' + $PublishRoot) -ForegroundColor Green
}
}
Write-Host ''
Write-Host ' Done. Dry-run validate the accumulated store with:' -ForegroundColor Cyan
Write-Host (' flask csv import --dir "' + $OutputRoot + '"') -ForegroundColor White
Write-Host ''

View File

@@ -0,0 +1,91 @@
# Collect-ShopDB.ps1 Overview
## Overview
- **Purpose**: A unified, read-only "site adoption" collector that runs on any
Windows PC, gathers facts about that PC plus any attached eDNC/DNC/UDC machine,
and merges them into a shared set of importable CSVs so a site can build its
ShopDB inventory one PC at a time.
- **Compatibility**: PowerShell 5.1+; CSV headers are aligned to the ShopDB
importer (`flask csv import --dir`).
## Configuration and destinations
- **Two-stage flow**: a fast local stage folder (`%ProgramData%\ShopDB\intake`)
where CSVs are built/merged, and an optional network store (a file share) that
the whole site accumulates into.
- Command-line params `-OutputRoot` and `-PublishRoot` override the in-script
CONFIG block.
- Other params: `-AssetNumber` (defaults to hostname),
`-AssetType`/`-ComputerType`/`-MachineType` (resolved by name), explicit
`-RegPath`/`-JsonPath` machine source overrides, and `-SkipMachine`/`-SkipApps`
switches for partial runs.
## Data model (the stores)
- Six importable CSVs, each with a defined header order and natural key:
`vendors.csv`, `models.csv`, `operatingsystems.csv`, `assets.csv`,
`computers.csv`, `machines.csv`.
- Column names follow ShopDB conventions (e.g., `assetid`, `modelnumberid`,
`controllervendorid`, `lastreporteddate`).
## PC collection (`Get-PcFacts`, read-only)
- Uses CIM/WMI (`Win32_ComputerSystem`, `Win32_OperatingSystem`, `Win32_BIOS`)
to gather hostname, vendor/manufacturer, model, OS caption, serial number,
last boot time, and logged-in user.
- Enumerates **network interfaces** (hardware adapters only: alias, IPv4, MAC).
- Enumerates **installed applications** from the registry Uninstall keys (name,
version, publisher), skipping system components - unless `-SkipApps` is set.
## Machine (eDNC/DNC/UDC) collection
- `Resolve-DncSource` searches in priority order: explicit JSON path -> explicit
.reg path -> live registry (`HKLM\...\GE Aircraft Engines\DNC`) -> local `.reg`
export files -> local `udc_settings_*.json` files.
- Three parsers normalize the different source formats into one shape:
`ConvertFrom-RegExport` (parses exported `.reg` files including dword decoding),
`ConvertFrom-LiveRegistry`, and `ConvertFrom-UdcJson`.
- Extracts machine number, site, control type, DNC type, and connection detail
(TCP address/port for ethernet, COM port/baud for serial).
- `Resolve-ControllerVendor` maps control type / NcIF to a controller vendor
(e.g., eFOCAS -> FANUC, Siemens, Okuma).
## Merge / upsert logic (`Merge-CsvStore`)
- Field-level UPSERT keyed on each table's natural key: existing rows are matched
case-insensitively, and empty incoming cells never overwrite previously captured
values - so partial or repeated runs fill gaps without wiping earlier data.
- CSV writing is UTF-8 without BOM, with proper quoting/escaping.
## Sidecar files (phase-2 data CSV can't carry)
- Per-PC `sidecars\computer_<assetnumber>.json`: interfaces + installed apps.
- Per-machine `sidecars\machine_<machinenumber>.json`: PC-to-machine link
(relationship "controls"), DNC/control details, and connection transport info.
## Publishing and output
- Optionally mirrors the whole local store to the network share
(`Copy-Item -Recurse -Force`), with a warning if run elevated (network writes
should run as standard user).
- Prints a summary and the follow-up dry-run command:
`flask csv import --dir "<OutputRoot>"`.
---
## Relevance to an "Asset Collector" package
This script is a solid template for one collector in a broader package. Notable
patterns worth generalizing across other collectors (existing databases,
Intune/Azure exports, etc.):
- **Normalized intermediate schema** (the six CSV stores + JSON sidecars) that
all sources converge on, decoupling collection from import.
- **Idempotent field-level merge** so multiple heterogeneous sources can
contribute to the same asset without clobbering each other.
- **Source-priority resolution** and per-format parsers - the same shape could
wrap an Intune/Azure `Get-MgDevice` export or a SQL query.
- **Machines, PCs, Applications** are already modeled here; an Intune/Azure
source would map naturally onto `computers.csv` + the installed-apps sidecar,
while a MES/CNC database could feed `machines.csv`.

View File

@@ -0,0 +1,5 @@
assetnumber,name,serialnumber,assettypeid,statusid,locationid,businessunitid,notes
SAMPLE-PC-01,Shopfloor PC 01,DELLSN0001,computer,In Use,A1,Gage Lab,Sample collected device
SAMPLE-PC-02,CMM PC 02,DELLSN0002,computer,In Use,A1,Gage Lab,Sample collected device
SAMPLE-MC-01,Zeiss CMM 01,ZEISSSN0001,machine,In Use,A1,Gage Lab,Sample collected device attached to SAMPLE-PC-02
SAMPLE-MC-02,Haas VF-2SS 02,HAASSN0002,machine,In Use,A1,Gage Lab,Sample collected standalone CNC
1 assetnumber name serialnumber assettypeid statusid locationid businessunitid notes
2 SAMPLE-PC-01 Shopfloor PC 01 DELLSN0001 computer In Use A1 Gage Lab Sample collected device
3 SAMPLE-PC-02 CMM PC 02 DELLSN0002 computer In Use A1 Gage Lab Sample collected device
4 SAMPLE-MC-01 Zeiss CMM 01 ZEISSSN0001 machine In Use A1 Gage Lab Sample collected device attached to SAMPLE-PC-02
5 SAMPLE-MC-02 Haas VF-2SS 02 HAASSN0002 machine In Use A1 Gage Lab Sample collected standalone CNC

View File

@@ -0,0 +1,3 @@
assetid,computertypeid,hostname,osid,vendorid,modelnumberid,loggedinuser
SAMPLE-PC-01,Shopfloor PC,WKS-SAMPLE01,Windows 10 Enterprise,Dell,OptiPlex 7090,EXAMPLE\operator1
SAMPLE-PC-02,CMM PC,WKS-SAMPLE02,Windows 11 Enterprise,Dell,Precision 3660,EXAMPLE\operator2
1 assetid computertypeid hostname osid vendorid modelnumberid loggedinuser
2 SAMPLE-PC-01 Shopfloor PC WKS-SAMPLE01 Windows 10 Enterprise Dell OptiPlex 7090 EXAMPLE\operator1
3 SAMPLE-PC-02 CMM PC WKS-SAMPLE02 Windows 11 Enterprise Dell Precision 3660 EXAMPLE\operator2

View File

@@ -0,0 +1,3 @@
assetid,machinetypeid,vendorid,modelnumberid,requiresmanualconfig,islocationonly
SAMPLE-MC-01,CMM,Zeiss,CONTURA G2,0,0
SAMPLE-MC-02,CNC,Haas Automation,VF-2SS,0,0
1 assetid machinetypeid vendorid modelnumberid requiresmanualconfig islocationonly
2 SAMPLE-MC-01 CMM Zeiss CONTURA G2 0 0
3 SAMPLE-MC-02 CNC Haas Automation VF-2SS 0 0

View File

@@ -0,0 +1,5 @@
modelnumber,vendorid
OptiPlex 7090,Dell
Precision 3660,Dell
VF-2SS,Haas Automation
CONTURA G2,Zeiss
1 modelnumber vendorid
2 OptiPlex 7090 Dell
3 Precision 3660 Dell
4 VF-2SS Haas Automation
5 CONTURA G2 Zeiss

View File

@@ -0,0 +1,3 @@
osname
Windows 10 Enterprise
Windows 11 Enterprise
1 osname
2 Windows 10 Enterprise
3 Windows 11 Enterprise

View File

@@ -0,0 +1,4 @@
vendor
Dell
Haas Automation
Zeiss
1 vendor
2 Dell
3 Haas Automation
4 Zeiss