diff --git a/index.ts b/index.ts index 9bee4a6..6ec9664 100644 --- a/index.ts +++ b/index.ts @@ -1,2 +1,3 @@ export { getReference } from './utils/tags.js'; export { identity } from './utils/identity.js'; +export { selector } from './utils/selector.js'; diff --git a/utils/edgeScl.ts b/utils/edgeScl.ts new file mode 100644 index 0000000..1554a02 --- /dev/null +++ b/utils/edgeScl.ts @@ -0,0 +1,470 @@ +export const edgeScl = ` + +
+ TrainingIEC61850 + + + +
+ + + + + + 100.0 + +
+

192.168.210.111

+

255.255.255.0

+

192.168.210.1

+

1,3,9999,23

+

23

+

00000001

+

0001

+

0001

+
+ +
+

01-0C-CD-01-00-10

+

005

+

4

+

0010

+
+
+ +

RJ45

+
+ +

RJ45

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + textContent + IED2 + IED2 + IED3 + IED4 + IED1 + IED2 + IED3 + IED5 + + + + + + + status-only + + + + + + + sbo-with-enhanced-security + + + + + + + status-only + + + + + + + + + 1 + + 1 + 2 + 3 + + + + sbo-with-enhanced-security + + + + + + + + + + status-only + + + + + + + + + + + + + + + + + + + status-only + + + + + + + + + direct-with-normal-security + + + + + + + sbo-with-normal-security + s + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + sbo-with-enhanced-security + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + IEC 61850-8-1:2003 + + + + + + + + IEC 61850-8-1:2003 + + + + + + + + + IEC 61850-8-2:2021 + + + + + + + + + + status-only + direct-with-normal-security + sbo-with-normal-security + direct-with-enhanced-security + sbo-with-enhanced-security + + + on + blocked + test + test/blocked + off + + + Ok + Warning + Alarm + + + not-supported + bay-control + station-control + remote-control + automatic-bay + automatic-station + automatic-remote + maintenance + process + + +
`; diff --git a/utils/find.spec.ts b/utils/find.spec.ts new file mode 100644 index 0000000..f58176a --- /dev/null +++ b/utils/find.spec.ts @@ -0,0 +1,59 @@ +import { expect } from '@open-wc/testing'; + +import { validScl } from './validScl.js'; + +import { find, isPublic, selectorTags } from './find.js'; +import { identity } from './identity.js'; + +const doc = new DOMParser().parseFromString(validScl, 'application/xml')!; + +describe('Function returning query selector', () => { + it('returns negation pseudo-class for identity of type NaN', () => + expect(find(doc, 'Association', NaN)).to.be.null); + + it('returns tagName with non SCL tag', () => + expect(find(doc, 'LNodeSpec', 'someLNodeSpec')).to.be.null); + + it('returns null for invalid extRefIdentity with non SCL tag', () => { + expect(find(doc, 'ExtRef', '@intAddr5]')).to.be.null; + expect(find(doc, 'ExtRef', ':GCB CBSW/ LLN0 / 2 stVal')).to.be.null; + expect(find(doc, 'Substation', 'IED1')).to.be.null; + }); + + it('returns null for invalid iEDNameIdentity with non SCL tag', () => + expect(find(doc, 'IEDName', '@invalidId]')).to.be.null); + + it('returns null for invalid pIdentity with non SCL tag', () => { + expect(find(doc, 'P', '@invalidId')).to.be.null; + expect(find(doc, 'P', 'IED1 P1> [0]')).to.be.null; + }); + + it('returns null for invalid protNsIdentity with non SCL tag', () => + expect(find(doc, 'ProtNs', '@invalidId')).to.be.null); + + it('returns null for invalid valIdentity with non SCL tag', () => + expect(find(doc, 'Val', '@invalidId')).to.be.null); + + it('returns null for LNode identity without lnType', () => + expect(find(doc, 'LNode', '(IED1)')).to.be.null); + + it('returns null for LNode identity without lnType', () => { + expect(find(doc, 'DAI', 'stVal')).to.be.null; + }); + + it('returns correct element for all tags', () => { + Object.keys(selectorTags).forEach(tag => { + const elements = Array.from(doc.querySelectorAll(tag)).filter( + item => !item.closest('Private') + ); + + elements.forEach(element => { + if (element && isPublic(element)) + expect(element).to.satisfy( + // eslint-disable-next-line no-shadow + (element: Element) => element === find(doc, tag, identity(element)) + ); + }); + }); + }); +}); diff --git a/utils/find.ts b/utils/find.ts new file mode 100644 index 0000000..95b55ac --- /dev/null +++ b/utils/find.ts @@ -0,0 +1,873 @@ +/* eslint-disable no-use-before-define */ +import { isSCLTag, parentTags, SCLTag } from './tags.js'; + +type IndexedSCLTags = 'ExtRef' | 'IEDName' | 'P' | 'ProtNs' | 'Val'; + +export const voidSelector = ':not(*)'; + +function crossProduct(...arrays: T[][]): T[][] { + return arrays.reduce( + (a, b) => a.flatMap(d => b.map(e => [d, e].flat())), + [[]] + ); +} + +function pathParts(identity: string): [string, string] { + const path = identity.split('>'); + const end = path.pop() ?? ''; + const start = path.join('>'); + return [start, end]; +} + +function hitemSelector(tagName: SCLTag, identity: string): string { + const [version, revision] = identity.split('\t'); + + if (!version || !revision) return voidSelector; + + return `${tagName}[version="${version}"][revision="${revision}"]`; +} + +function terminalSelector(tagName: SCLTag, identity: string): string { + const [parentIdentity, connectivityNode] = pathParts(identity); + + const parentSelectors = parentTags(tagName).flatMap(parentTag => + selector(parentTag, parentIdentity).split(',') + ); + + return crossProduct( + parentSelectors, + ['>'], + [`${tagName}[connectivityNode="${connectivityNode}"]`] + ) + .map(strings => strings.join('')) + .join(','); +} + +function lNodeSelector(tagName: SCLTag, identity: string): string { + if (identity.endsWith(')')) { + const [parentIdentity, childIdentity] = pathParts(identity); + const [lnClass, lnType] = childIdentity + .substring(1, childIdentity.length - 1) + .split(' '); + + if (!lnClass || !lnType) return voidSelector; + + const parentSelectors = parentTags(tagName).flatMap(parentTag => + selector(parentTag, parentIdentity).split(',') + ); + + return crossProduct( + parentSelectors, + ['>'], + [`${tagName}[iedName="None"][lnClass="${lnClass}"][lnType="${lnType}"]`] + ) + .map(strings => strings.join('')) + .join(','); + } + + const [iedName, ldInst, prefix, lnClass, lnInst] = identity.split(/[ /]/); + + if (!iedName || !ldInst || !lnClass) return voidSelector; + + const [ + iedNameSelectors, + ldInstSelectors, + prefixSelectors, + lnClassSelectors, + lnInstSelectors, + ] = [ + [`[iedName="${iedName}"]`], + ldInst === '(Client)' + ? [':not([ldInst])', '[ldInst=""]'] + : [`[ldInst="${ldInst}"]`], + prefix ? [`[prefix="${prefix}"]`] : [':not([prefix])', '[prefix=""]'], + [`[lnClass="${lnClass}"]`], + lnInst ? [`[lnInst="${lnInst}"]`] : [':not([lnInst])', '[lnInst=""]'], + ]; + + return crossProduct( + [tagName], + iedNameSelectors, + ldInstSelectors, + prefixSelectors, + lnClassSelectors, + lnInstSelectors + ) + .map(strings => strings.join('')) + .join(','); +} + +function kDCSelector(tagName: SCLTag, identity: string): string { + const [parentIdentity, childIdentity] = pathParts(identity); + const [iedName, apName] = childIdentity.split(' '); + return `${selector( + 'IED', + parentIdentity + )}>${tagName}[iedName="${iedName}"][apName="${apName}"]`; +} + +function associationSelector(tagName: SCLTag, identity: string): string { + const [parentIdentity, childIdentity] = pathParts(identity); + + const [iedName, ldInst, prefix, lnClass, lnInst] = + childIdentity.split(/[ /]/); + + const parentSelectors = parentTags(tagName).flatMap(parentTag => + selector(parentTag, parentIdentity).split(',') + ); + + const [ + iedNameSelectors, + ldInstSelectors, + prefixSelectors, + lnClassSelectors, + lnInstSelectors, + ] = [ + [`[iedName="${iedName}"]`], + [`[ldInst="${ldInst}"]`], + prefix ? [`[prefix="${prefix}"]`] : [':not([prefix])', '[prefix=""]'], + [`[lnClass="${lnClass}"]`], + lnInst ? [`[lnInst="${lnInst}"]`] : [':not([lnInst])', '[lnInst=""]'], + ]; + + return crossProduct( + parentSelectors, + ['>'], + [tagName], + iedNameSelectors, + ldInstSelectors, + prefixSelectors, + lnClassSelectors, + lnInstSelectors + ) + .map(strings => strings.join('')) + .join(','); +} + +function lDeviceSelector(tagName: SCLTag, identity: string): string { + const [iedName, inst] = identity.split('>>'); + + if (!inst) return voidSelector; + + return `IED[name="${iedName}"] ${tagName}[inst="${inst}"]`; +} + +function fCDASelector(tagName: SCLTag, identity: string): string { + const [parentIdentity, childIdentity] = pathParts(identity); + + const [ldInst, prefix, lnClass, lnInst] = childIdentity.split(/[ /.]/); + + const matchDoDa = childIdentity.match( + /.([A-Z][A-Za-z0-9.]*) ([A-Za-z0-9.]*) \(/ + ); + const doName = matchDoDa && matchDoDa[1] ? matchDoDa[1] : ''; + const daName = matchDoDa && matchDoDa[2] ? matchDoDa[2] : ''; + + const matchFx = childIdentity.match(/\(([A-Z]{2})/); + const matchIx = childIdentity.match(/ \[([0-9]{1,2})\]/); + + const fc = matchFx && matchFx[1] ? matchFx[1] : ''; + const ix = matchIx && matchIx[1] ? matchIx[1] : ''; + + const [ + parentSelectors, + ldInstSelectors, + prefixSelectors, + lnClassSelectors, + lnInstSelectors, + doNameSelectors, + daNameSelectors, + fcSelectors, + ixSelectors, + ] = [ + parentTags(tagName).flatMap(parentTag => + selector(parentTag, parentIdentity).split(',') + ), + [`[ldInst="${ldInst}"]`], + prefix ? [`[prefix="${prefix}"]`] : [':not([prefix])', '[prefix=""]'], + [`[lnClass="${lnClass}"]`], + lnInst ? [`[lnInst="${lnInst}"]`] : [':not([lnInst])', '[lnInst=""]'], + [`[doName="${doName}"]`], + daName ? [`[daName="${daName}"]`] : [':not([daName])', '[daName=""]'], + [`[fc="${fc}"]`], + ix ? [`[ix="${ix}"]`] : [':not([ix])', '[ix=""]'], + ]; + + return crossProduct( + parentSelectors, + ['>'], + [tagName], + ldInstSelectors, + prefixSelectors, + lnClassSelectors, + lnInstSelectors, + doNameSelectors, + daNameSelectors, + fcSelectors, + ixSelectors + ) + .map(strings => strings.join('')) + .join(','); +} + +function lNSelector(tagName: SCLTag, identity: string): string { + const [parentIdentity, childIdentity] = pathParts(identity); + + const parentSelectors = parentTags(tagName).flatMap(parentTag => + selector(parentTag, parentIdentity).split(',') + ); + + const [prefix, lnClass, inst] = childIdentity.split(' '); + + if (!lnClass) return voidSelector; + + const [prefixSelectors, lnClassSelectors, instSelectors] = [ + prefix ? [`[prefix="${prefix}"]`] : [':not([prefix])', '[prefix=""]'], + [`[lnClass="${lnClass}"]`], + [`[inst="${inst}"]`], + ]; + + return crossProduct( + parentSelectors, + ['>'], + [tagName], + prefixSelectors, + lnClassSelectors, + instSelectors + ) + .map(strings => strings.join('')) + .join(','); +} + +function clientLNSelector(tagName: SCLTag, identity: string): string { + const [parentIdentity, childIdentity] = pathParts(identity); + + const parentSelectors = parentTags(tagName).flatMap(parentTag => + selector(parentTag, parentIdentity).split(',') + ); + + const [iedName, apRef, ldInst, prefix, lnClass, lnInst] = + childIdentity.split(/[ /]/); + + const [ + iedNameSelectors, + apRefSelectors, + ldInstSelectors, + prefixSelectors, + lnClassSelectors, + lnInstSelectors, + ] = [ + iedName ? [`[iedName="${iedName}"]`] : [':not([iedName])', '[iedName=""]'], + apRef ? [`[apRef="${apRef}"]`] : [':not([apRef])', '[apRef=""]'], + ldInst ? [`[ldInst="${ldInst}"]`] : [':not([ldInst])', '[ldInst=""]'], + prefix ? [`[prefix="${prefix}"]`] : [':not([prefix])', '[prefix=""]'], + [`[lnClass="${lnClass}"]`], + lnInst ? [`[lnInst="${lnInst}"]`] : [':not([lnInst])', '[lnInst=""]'], + ]; + + return crossProduct( + parentSelectors, + ['>'], + [tagName], + iedNameSelectors, + apRefSelectors, + ldInstSelectors, + prefixSelectors, + lnClassSelectors, + lnInstSelectors + ) + .map(strings => strings.join('')) + .join(','); +} + +function ixNamingSelector( + tagName: SCLTag, + identity: string, + depth = -1 +): string { + // eslint-disable-next-line no-param-reassign + if (depth === -1) depth = identity.split('>').length; + + const [parentIdentity, childIdentity] = pathParts(identity); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [_0, name, _1, ix] = + childIdentity.match(/([^[]*)(\[([0-9]*)\])?/) ?? []; + + if (!name) return voidSelector; + + const parentSelectors = parentTags(tagName) + .flatMap(parentTag => + parentTag === 'SDI' + ? ixNamingSelector(parentTag, parentIdentity, depth - 1).split(',') + : selector(parentTag, parentIdentity).split(',') + ) + // eslint-disable-next-line no-shadow + .filter(selector => !selector.startsWith(voidSelector)); + + if (parentSelectors.length === 0) return voidSelector; + + const [nameSelectors, ixSelectors] = [ + [`[name="${name}"]`], + ix ? [`[ix="${ix}"]`] : ['[ix=""]', ':not([ix])'], + ]; + + return crossProduct( + parentSelectors, + ['>'], + [tagName], + nameSelectors, + ixSelectors + ) + .map(strings => strings.join('')) + .join(','); +} + +function connectedAPSelector(tagName: SCLTag, identity: string): string { + const [iedName, apName] = identity.split(' '); + if (!iedName || !apName) return voidSelector; + return `${tagName}[iedName="${iedName}"][apName="${apName}"]`; +} + +function controlBlockSelector(tagName: SCLTag, identity: string): string { + const [ldInst, cbName] = identity.split(' '); + + if (!ldInst || !cbName) return voidSelector; + + return `${tagName}[ldInst="${ldInst}"][cbName="${cbName}"]`; +} + +function physConnSelector(tagName: SCLTag, identity: string): string { + const [parentIdentity, pcType] = pathParts(identity); + + const [parentSelectors, typeSelectors] = [ + parentTags(tagName).flatMap(parentTag => + selector(parentTag, parentIdentity).split(',') + ), + pcType ? [`[type="${pcType}"]`] : [''], + ]; + + return crossProduct(parentSelectors, ['>'], [tagName], typeSelectors) + .map(strings => strings.join('')) + .join(','); +} + +function enumValSelector(tagName: SCLTag, identity: string): string { + const [parentIdentity, ord] = pathParts(identity); + return `${selector('EnumType', parentIdentity)}>${tagName}[ord="${ord}"]`; +} + +function sCLSelector(): string { + return ':root'; +} + +function namingSelector(tagName: SCLTag, identity: string, depth = -1): string { + // eslint-disable-next-line no-param-reassign + if (depth === -1) depth = identity.split('>').length; + + const [parentIdentity, name] = pathParts(identity); + if (!name) return voidSelector; + + // eslint-disable-next-line prefer-destructuring + const parents = parentTags(tagName) as SCLTag[]; + + const parentSelectors = parents + .flatMap(parentTag => + selectorTags[parentTag] === selectorTags.Substation + ? namingSelector(parentTag, parentIdentity, depth - 1).split(',') + : selector(parentTag, parentIdentity).split(',') + ) + // eslint-disable-next-line no-shadow + .filter(selector => !selector.startsWith(voidSelector)); + + if (parentSelectors.length === 0) return voidSelector; + + return crossProduct(parentSelectors, ['>'], [tagName], [`[name="${name}"]`]) + .map(strings => strings.join('')) + .join(','); +} + +function singletonSelector(tagName: SCLTag, identity: string): string { + // eslint-disable-next-line prefer-destructuring + const parents = parentTags(tagName) as SCLTag[]; + + const parentSelectors = parents + .flatMap(parentTag => selector(parentTag, identity).split(',')) + // eslint-disable-next-line no-shadow + .filter(selector => !selector.startsWith(voidSelector)); + + if (parentSelectors.length === 0) return voidSelector; + + return crossProduct(parentSelectors, ['>'], [tagName]) + .map(strings => strings.join('')) + .join(','); +} + +function idNamingSelector(tagName: SCLTag, identity: string): string { + const id = identity.replace(/^#/, ''); + + if (!id) return voidSelector; + + return `${tagName}[id="${id}"]`; +} + +type SelectorFunction = (tagName: SCLTag, identity: string) => string; + +export const selectorTags: Record = { + AccessControl: singletonSelector, + AccessPoint: namingSelector, + Address: singletonSelector, + Association: associationSelector, + Authentication: singletonSelector, + BDA: namingSelector, + BitRate: singletonSelector, + Bay: namingSelector, + ClientLN: clientLNSelector, + ClientServices: singletonSelector, + CommProt: singletonSelector, + Communication: singletonSelector, + ConductingEquipment: namingSelector, + ConfDataSet: singletonSelector, + ConfLdName: singletonSelector, + ConfLNs: singletonSelector, + ConfLogControl: singletonSelector, + ConfReportControl: singletonSelector, + ConfSG: singletonSelector, + ConfSigRef: singletonSelector, + ConnectedAP: connectedAPSelector, + ConnectivityNode: namingSelector, + DA: namingSelector, + DAI: ixNamingSelector, + DAType: idNamingSelector, + DO: namingSelector, + DOI: namingSelector, + DOType: idNamingSelector, + DataObjectDirectory: singletonSelector, + DataSet: namingSelector, + DataSetDirectory: singletonSelector, + DataTypeTemplates: singletonSelector, + DynAssociation: singletonSelector, + DynDataSet: singletonSelector, + EnumType: idNamingSelector, + EnumVal: enumValSelector, + EqFunction: namingSelector, + EqSubFunction: namingSelector, + ExtRef: () => voidSelector, + FCDA: fCDASelector, + FileHandling: singletonSelector, + Function: namingSelector, + GeneralEquipment: namingSelector, + GetCBValues: singletonSelector, + GetDataObjectDefinition: singletonSelector, + GetDataSetValue: singletonSelector, + GetDirectory: singletonSelector, + GOOSE: singletonSelector, + GOOSESecurity: namingSelector, + GSE: controlBlockSelector, + GSEDir: singletonSelector, + GSEControl: namingSelector, + GSESettings: singletonSelector, + GSSE: singletonSelector, + Header: singletonSelector, + History: singletonSelector, + Hitem: hitemSelector, + IED: namingSelector, + IEDName: () => voidSelector, + Inputs: singletonSelector, + IssuerName: singletonSelector, + KDC: kDCSelector, + LDevice: lDeviceSelector, + LN: lNSelector, + LN0: singletonSelector, + LNode: lNodeSelector, + LNodeType: idNamingSelector, + Line: namingSelector, + Log: namingSelector, + LogControl: namingSelector, + LogSettings: singletonSelector, + MaxTime: singletonSelector, + McSecurity: singletonSelector, + MinTime: singletonSelector, + NeutralPoint: terminalSelector, + OptFields: singletonSelector, + P: () => voidSelector, + PhysConn: physConnSelector, + PowerTransformer: namingSelector, + Private: () => voidSelector, + Process: namingSelector, + ProtNs: () => voidSelector, + Protocol: singletonSelector, + ReadWrite: singletonSelector, + RedProt: singletonSelector, + ReportControl: namingSelector, + ReportSettings: singletonSelector, + RptEnabled: singletonSelector, + SamplesPerSec: singletonSelector, + SampledValueControl: namingSelector, + SecPerSamples: singletonSelector, + SCL: sCLSelector, + SDI: ixNamingSelector, + SDO: namingSelector, + Server: singletonSelector, + ServerAt: singletonSelector, + Services: singletonSelector, + SetDataSetValue: singletonSelector, + SettingControl: singletonSelector, + SettingGroups: singletonSelector, + SGEdit: singletonSelector, + SmpRate: singletonSelector, + SMV: controlBlockSelector, + SmvOpts: singletonSelector, + SMVsc: singletonSelector, + SMVSecurity: namingSelector, + SMVSettings: singletonSelector, + SubEquipment: namingSelector, + SubFunction: namingSelector, + SubNetwork: namingSelector, + Subject: singletonSelector, + Substation: namingSelector, + SupSubscription: singletonSelector, + TapChanger: namingSelector, + Terminal: terminalSelector, + Text: singletonSelector, + TimerActivatedControl: singletonSelector, + TimeSyncProt: singletonSelector, + TransformerWinding: namingSelector, + TrgOps: singletonSelector, + Val: () => voidSelector, + ValueHandling: singletonSelector, + Voltage: singletonSelector, + VoltageLevel: namingSelector, +}; + +function selector(tagName: SCLTag, identity: string | number): string { + if (typeof identity !== 'string') return voidSelector; + + return selectorTags[tagName](tagName, identity); +} + +function findExtRef( + root: XMLDocument | Element | DocumentFragment, + tagName: IndexedSCLTags, + identity: string +): Element | null { + const [parentIdentity, childIdentity] = pathParts(identity); + + const parentSelectors = parentTags(tagName).flatMap(parentTag => + selector(parentTag, parentIdentity).split(',') + ); + + if (childIdentity.endsWith(']')) { + const [intAddr] = childIdentity.split('['); + const intAddrSelectors = [`[intAddr="${intAddr}"]`]; + + const index = + childIdentity && + childIdentity.match(/\[([0-9]+)\]/) && + childIdentity.match(/\[([0-9]+)\]/)![1] + ? parseFloat(childIdentity.match(/\[([0-9]+)\]/)![1]) + : NaN; + + const extRefSelector = crossProduct( + parentSelectors, + ['>'], + [tagName], + intAddrSelectors + ) + .map(strings => strings.join('')) + .join(','); + + return ( + Array.from(root.querySelectorAll(extRefSelector)).filter(isPublic)[ + index + ] ?? null + ); + } + + let iedName; + let ldInst; + let prefix; + let lnClass; + let lnInst; + let doName; + let daName; + let serviceType; + let srcCBName; + let srcLDInst; + let srcPrefix; + let srcLNClass; + let srcLNInst; + + if (!childIdentity.includes(':') && !childIdentity.includes('@')) { + [iedName, ldInst, prefix, lnClass, lnInst, doName, daName] = + childIdentity.split(/[ /]/); + } else if (childIdentity.includes(':') && !childIdentity.includes('@')) { + [ + serviceType, + srcCBName, + srcLDInst, + srcPrefix, + srcLNClass, + srcLNInst, + iedName, + ldInst, + prefix, + lnClass, + lnInst, + doName, + daName, + ] = childIdentity.split(/[ /:]/); + } + + const [ + iedNameSelectors, + ldInstSelectors, + prefixSelectors, + lnClassSelectors, + lnInstSelectors, + doNameSelectors, + daNameSelectors, + serviceTypeSelectors, + srcCBNameSelectors, + srcLDInstSelectors, + srcPrefixSelectors, + srcLNClassSelectors, + srcLNInstSelectors, + ] = [ + iedName ? [`[iedName="${iedName}"]`] : [':not([iedName])'], + ldInst ? [`[ldInst="${ldInst}"]`] : [':not([ldInst])', '[ldInst=""]'], + prefix ? [`[prefix="${prefix}"]`] : [':not([prefix])', '[prefix=""]'], + lnClass ? [`[lnClass="${lnClass}"]`] : [':not([lnClass])'], + lnInst ? [`[lnInst="${lnInst}"]`] : [':not([lnInst])', '[lnInst=""]'], + doName ? [`[doName="${doName}"]`] : [':not([doName])'], + daName ? [`[daName="${daName}"]`] : [':not([daName])', '[daName=""]'], + serviceType + ? [`[serviceType="${serviceType}"]`] + : [':not([serviceType])', '[serviceType=""]'], + srcCBName + ? [`[srcCBName="${srcCBName}"]`] + : [':not([srcCBName])', '[srcCBName=""]'], + srcLDInst + ? [`[srcLDInst="${srcLDInst}"]`] + : [':not([srcLDInst])', '[srcLDInst=""]'], + srcPrefix + ? [`[srcPrefix="${srcPrefix}"]`] + : [':not([srcPrefix])', '[srcPrefix=""]'], + srcLNClass + ? [`[srcLNClass="${srcLNClass}"]`] + : [':not([srcLNClass])', '[srcLNClass=""]'], + srcLNInst + ? [`[srcLNInst="${srcLNInst}"]`] + : [':not([srcLNInst])', '[srcLNInst=""]'], + ]; + + const extRefSelector = crossProduct( + parentSelectors, + ['>'], + [tagName], + iedNameSelectors, + ldInstSelectors, + prefixSelectors, + lnClassSelectors, + lnInstSelectors, + doNameSelectors, + daNameSelectors, + serviceTypeSelectors, + srcCBNameSelectors, + srcLDInstSelectors, + srcPrefixSelectors, + srcLNClassSelectors, + srcLNInstSelectors + ) + .map(strings => strings.join('')) + .join(','); + + return ( + Array.from(root.querySelectorAll(extRefSelector)).filter(isPublic)[0] ?? + null + ); +} + +function findIEDName( + root: XMLDocument | Element | DocumentFragment, + tagName: IndexedSCLTags, + identity: string +): Element | null { + const [parentIdentity, childIdentity] = pathParts(identity); + + const [iedName, apRef, ldInst, prefix, lnClass, lnInst] = + childIdentity.split(/[ /]/); + + const [ + parentSelectors, + apRefSelectors, + ldInstSelectors, + prefixSelectors, + lnClassSelectors, + lnInstSelectors, + ] = [ + parentTags(tagName).flatMap(parentTag => + selector(parentTag, parentIdentity).split(',') + ), + apRef ? [`[apRef="${apRef}"]`] : [':not([apRef])', '[apRef=""]'], + ldInst ? [`[ldInst="${ldInst}"]`] : [':not([ldInst])', '[ldInst=""]'], + prefix ? [`[prefix="${prefix}"]`] : [':not([prefix])', '[prefix=""]'], + lnClass ? [`[lnClass="${lnClass}"]`] : [':not([lnClass])', '[lnClass=""]'], + lnInst ? [`[lnInst="${lnInst}"]`] : [':not([lnInst])', '[lnInst=""]'], + ]; + + const iEDNameSelector = crossProduct( + parentSelectors, + ['>'], + [tagName], + apRefSelectors, + ldInstSelectors, + prefixSelectors, + lnClassSelectors, + lnInstSelectors + ) + .map(strings => strings.join('')) + .join(','); + + return ( + Array.from(root.querySelectorAll(iEDNameSelector)) + .filter(isPublic) + .find(iEDName => iEDName.textContent === iedName) ?? null + ); +} + +function findP( + root: XMLDocument | Element | DocumentFragment, + tagName: IndexedSCLTags, + identity: string +): Element | null { + const [parentIdentity, childIdentity] = pathParts(identity); + + const [type] = childIdentity.split(' '); + const index = + childIdentity && + childIdentity.match(/\[([0-9]+)\]/) && + childIdentity.match(/\[([0-9]+)\]/)![1] + ? parseFloat(childIdentity.match(/\[([0-9]+)\]/)![1]) + : NaN; + + const [parentSelectors, typeSelectors] = [ + parentTags(tagName).flatMap(parentTag => + selector(parentTag, parentIdentity).split(',') + ), + [`[type="${type}"]`], + ]; + + const pSelector = crossProduct( + parentSelectors, + ['>'], + [tagName], + typeSelectors + ) + .map(strings => strings.join('')) + .join(','); + + return Number.isNaN(index) + ? Array.from(root.querySelectorAll(pSelector)).find(isPublic) ?? null + : Array.from(root.querySelectorAll(pSelector)).filter(isPublic)[index] ?? + null; +} + +function findProtNs( + root: XMLDocument | Element | DocumentFragment, + tagName: IndexedSCLTags, + identity: string +): Element | null { + const [parentIdentity, childIdentity] = pathParts(identity); + + const [type, protNsContent] = childIdentity.split('\t'); + + const [parentSelectors, typeSelector] = [ + parentTags(tagName).flatMap(parentTag => + selector(parentTag, parentIdentity).split(',') + ), + type && type !== '8-MMS' + ? [`[type="${type}"]`] + : [':not([type])', '[type="8-MMS"]'], + ]; + + const protNsSelector = crossProduct( + parentSelectors, + ['>'], + [tagName], + typeSelector + ) + .map(strings => strings.join('')) + .join(','); + + return ( + Array.from(root.querySelectorAll(protNsSelector)) + .filter(isPublic) + .find(protNs => protNs.textContent === protNsContent) ?? null + ); +} + +function findVal( + root: XMLDocument | Element | DocumentFragment, + tagName: IndexedSCLTags, + identity: string +): Element | null { + const [parentIdentity, childIdentity] = pathParts(identity); + + const [sGroup, indexText] = childIdentity.split(' '); + const index = parseFloat(indexText); + + const parentSelectors = parentTags(tagName).flatMap(parentTag => + selector(parentTag, parentIdentity).split(',') + ); + + const [nameSelectors] = [sGroup ? [`[sGroup="${sGroup}"]`] : ['']]; + + const valSelector = crossProduct( + parentSelectors, + ['>'], + [tagName], + nameSelectors + ) + .map(strings => strings.join('')) + .join(','); + + return ( + Array.from(root.querySelectorAll(valSelector)).filter(isPublic)[index] ?? + null + ); +} + +type FindFunction = ( + doc: XMLDocument | Element | DocumentFragment, + tagName: 'ExtRef', + identity: string +) => Element | null; + +const sclTags: Record = { + ExtRef: findExtRef, + IEDName: findIEDName, + P: findP, + ProtNs: findProtNs, + Val: findVal, +}; + +export function isPublic(element: Element): boolean { + return !element.closest('Private'); +} + +export function find( + root: XMLDocument | Element | DocumentFragment, + tagName: string, + identity: string | number +): Element | null { + if (typeof identity !== 'string' || !isSCLTag(tagName)) return null; + + if (sclTags[tagName]) return sclTags[tagName](root, tagName, identity); + + return ( + Array.from( + root.querySelectorAll(selectorTags[tagName](tagName, identity)) + ).filter(isPublic)[0] ?? null + ); +} diff --git a/utils/identity.spec.ts b/utils/identity.spec.ts index 0e480b6..e906f94 100644 --- a/utils/identity.spec.ts +++ b/utils/identity.spec.ts @@ -1,9 +1,11 @@ import { expect } from '@open-wc/testing'; +import { edgeScl } from './edgeScl.js'; import { identity } from './identity.js'; import { validScl } from './validScl.js'; const scl = new DOMParser().parseFromString(validScl, 'application/xml'); +const sclEdge = new DOMParser().parseFromString(edgeScl, 'application/xml'); describe('identity', () => { it('returns NaN for element null', () => expect(identity(null)).to.be.NaN); @@ -14,6 +16,50 @@ describe('identity', () => { it('returns NaN for any private element', () => expect(identity(scl.querySelector('Private'))).to.be.NaN); + it('returns NaN for extension type PhysConn element', () => + expect(identity(sclEdge.querySelector('PhysConn[type="SomeOtherType"]'))).to + .be.NaN); + + it('returns NaN with orphan ExtRef element', () => { + const extRef = new DOMParser() + .parseFromString(``, 'application/xml') + .querySelector('ExtRef')!; + + expect(identity(extRef)).to.be.NaN; + }); + + it('returns NaN with orphan Val element', () => { + const val = new DOMParser() + .parseFromString(``, 'application/xml') + .querySelector('Val')!; + + expect(identity(val)).to.be.NaN; + }); + + it('returns NaN with orphan PhysConn element', () => { + const val = new DOMParser() + .parseFromString(``, 'application/xml') + .querySelector('PhysConn')!; + + expect(identity(val)).to.be.NaN; + }); + + it('returns NaN with orphan P element', () => { + const val = new DOMParser() + .parseFromString(`

`, 'application/xml') + .querySelector('P')!; + + expect(identity(val)).to.be.NaN; + }); + + it('returns NaN with orphan ProtNs element', () => { + const val = new DOMParser() + .parseFromString(``, 'application/xml') + .querySelector('ProtNs')!; + + expect(identity(val)).to.be.NaN; + }); + it('returns parent identity for singleton identities', () => { const element = scl.querySelector('Server')!; expect(identity(element)).to.equal(identity(element.parentElement!)); @@ -27,14 +73,14 @@ describe('identity', () => { KDC: 'IED1>IED1 P1', LDevice: 'IED1>>CircuitBreaker_CB1', IEDName: - 'IED1>>CircuitBreaker_CB1>GCB>IED2 P1 CircuitBreaker_CB1/ CSWI 1', + 'IED1>>CircuitBreaker_CB1>GCB>IED2 P1 CircuitBreaker_CB1/ XCBR 1', FCDA: 'IED1>>CircuitBreaker_CB1>GooseDataSet1>CircuitBreaker_CB1/ XCBR 1.Pos stVal (ST)', ExtRef: - 'IED1>>Disconnectors>DC CSWI 1>GOOSE:GCB CBSW/ LLN0 IED2 CBSW/ XSWI 2 Pos stVal@intAddr', - 'ExtRef:not([iedName])': 'IED1>>Disconnectors>DC CSWI 1>stVal-t[0]', + 'IED1>>Disconnectors>DC CSWI 1>GOOSE:GCB CBSW/ LLN0 IED2 CBSW/ XSWI 2 Pos stVal', + 'ExtRef[intAddr="stVal-t"]': 'IED1>>Disconnectors>DC CSWI 1>stVal-t[0]', LN: 'IED1>>CircuitBreaker_CB1> XCBR 1', ClientLN: - 'IED2>>CBSW> XSWI 1>ReportCb>IED1 P1 CircuitBreaker_CB1/ XCBR 1', + 'IED2>>CBSW> XSWI 1>ReportCb>IED1 P1 CircuitBreaker_CB1/DC XCBR 1', DAI: 'IED1>>CircuitBreaker_CB1> XCBR 1>Pos>ctlModel', SDI: 'IED1>>CircuitBreaker_CB1>CB CSWI 2>Pos>pulseConfig', Val: 'IED1>>CircuitBreaker_CB1> XCBR 1>Pos>ctlModel> 0', @@ -47,6 +93,7 @@ describe('identity', () => { ProtNs: '#Dummy.LLN0.Mod.SBOw>8-MMS\tIEC 61850-8-1:2003', Association: 'IED1>P1>IED3 MU01/ LLN0 ', LNode: 'IED2 CBSW/ XSWI 3', + 'SDI[ix="2"]': 'IED2>>CircuitBreaker_CB1> MHAN 1>HaAmp>har[2]', }; Object.keys(expectations).forEach(key => { diff --git a/utils/identity.ts b/utils/identity.ts index 7fd9c02..4be9bbb 100644 --- a/utils/identity.ts +++ b/utils/identity.ts @@ -50,7 +50,7 @@ function lDeviceIdentity(e: Element): string { return `${identity(e.closest('IED')!)}>>${e.getAttribute('inst')}`; } -function iEDNameIdentity(e: Element): string { +function iEDNameIdentity(e: Element): string | number { const iedName = e.textContent; const [apRef, ldInst, prefix, lnClass, lnInst] = [ 'apRef', @@ -59,6 +59,7 @@ function iEDNameIdentity(e: Element): string { 'lnClass', 'lnInst', ].map(name => e.getAttribute(name)); + return `${identity(e.parentElement)}>${iedName} ${apRef || ''} ${ ldInst || '' }/${prefix ?? ''} ${lnClass ?? ''} ${lnInst ?? ''}`; @@ -91,7 +92,7 @@ function extRefIdentity(e: Element): string | number { const intAddrIndex = Array.from( e.parentElement.querySelectorAll(`ExtRef[intAddr="${intAddr}"]`) ).indexOf(e); - if (!iedName) return `${parentIdentity}>${intAddr}[${intAddrIndex}]`; + if (intAddr) return `${parentIdentity}>${intAddr}[${intAddrIndex}]`; const [ ldInst, prefix, @@ -120,18 +121,27 @@ function extRefIdentity(e: Element): string | number { 'srcCBName', ].map(name => e.getAttribute(name)); + const defaultSrcPrefix = ''; + const finalSrcPrefix = srcPrefix ?? defaultSrcPrefix; + + const defaultSrcLNInst = ''; + const finalSrcLNInst = srcLNInst ?? defaultSrcLNInst; + const cbPath = srcCBName - ? `${serviceType}:${srcCBName} ${srcLDInst ?? ''}/${srcPrefix ?? ''} ${ - srcLNClass ?? '' - } ${srcLNInst ?? ''}` + ? `${serviceType}:${srcCBName} ${srcLDInst}/${finalSrcPrefix} ${srcLNClass} ${finalSrcLNInst}` : ''; - const dataPath = `${iedName} ${ldInst}/${prefix ?? ''} ${lnClass} ${ - lnInst ?? '' - } ${doName} ${daName || ''}`; - return `${parentIdentity}>${cbPath ? `${cbPath} ` : ''}${dataPath}${ - // eslint-disable-next-line no-useless-concat - intAddr ? '@' + `${intAddr}` : '' - }`; + + const defaultPrefix = ''; + const finalPrefix = prefix ?? defaultPrefix; + + const defaultLnInst = ''; + const finalLnInst = lnInst ?? defaultLnInst; + + const defaultDaName = ''; + const finalDaName = daName || defaultDaName; + + const dataPath = `${iedName} ${ldInst}/${finalPrefix} ${lnClass} ${finalLnInst} ${doName} ${finalDaName}`; + return `${parentIdentity}>${cbPath ? `${cbPath} ` : ''}${dataPath}`; } function lNIdentity(e: Element): string { @@ -166,7 +176,7 @@ function valIdentity(e: Element): string | number { const index = Array.from(e.parentElement.children) .filter(child => child.getAttribute('sGroup') === sGroup) .findIndex(child => child.isSameNode(e)); - return `${identity(e.parentElement)}>${sGroup ? `${sGroup}.` : ''} ${index}`; + return `${identity(e.parentElement)}>${sGroup ? `${sGroup}` : ''} ${index}`; } function connectedAPIdentity(e: Element): string { @@ -185,7 +195,7 @@ function controlBlockIdentity(e: Element): string { function physConnIdentity(e: Element): string | number { if (!e.parentElement) return NaN; - if (!e.parentElement.querySelector('PhysConn[type="RedConn"]')) return NaN; + const pcType = e.getAttribute('type'); if ( e.parentElement.children.length > 1 && @@ -212,10 +222,12 @@ function enumValIdentity(e: Element): string { return `${identity(e.parentElement)}>${e.getAttribute('ord')}`; } -function protNsIdentity(e: Element): string { - return `${identity(e.parentElement)}>${e.getAttribute('type') || '8-MMS'}\t${ - e.textContent - }`; +function protNsIdentity(e: Element): string | number { + if (!e.parentElement) return NaN; + + const type = e.getAttribute('type'); + + return `${identity(e.parentElement)}>${type || '8-MMS'}\t${e.textContent}`; } function sCLIdentity(): string { @@ -481,9 +493,6 @@ const tags: Record< PowerTransformer: { identity: namingIdentity, }, - Private: { - identity: () => NaN, - }, Process: { identity: namingIdentity, }, diff --git a/utils/tags.spec.ts b/utils/tags.spec.ts index a436b51..a89eb6a 100644 --- a/utils/tags.spec.ts +++ b/utils/tags.spec.ts @@ -1,6 +1,14 @@ import { expect } from '@open-wc/testing'; -import { getReference } from './tags.js'; +import { getReference, parentTags } from './tags.js'; + +describe('parentTag', () => { + it('returns empty string with non SCL tag', () => + expect(parentTags('LNodeSpec').length).to.equal(0)); + + it('returns empty all possible parents for SCL tag', () => + expect(parentTags('SDI')).to.deep.equal(['DOI', 'SDI'])); +}); describe('getReference', () => { it('returns null for invalid SCL tag', () => { diff --git a/utils/tags.ts b/utils/tags.ts index 8b48c4c..c227c84 100644 --- a/utils/tags.ts +++ b/utils/tags.ts @@ -157,7 +157,7 @@ const sCLTags = [ 'SecPerSamples', ] as const; -type SCLTag = (typeof sCLTags)[number]; +export type SCLTag = (typeof sCLTags)[number]; const tBaseNameSequence = ['Text', 'Private'] as const; const tNamingSequence = [...tBaseNameSequence] as const; @@ -833,14 +833,14 @@ export function isSCLTag(tag: string): tag is SCLTag { } /** @returns parent `tagName` s for SCL (2007B4) element tag */ -/** export function parentTags(tagName: string): string[] { +export function parentTags(tagName: string): SCLTag[] { if (!isSCLTag(tagName)) return []; return tags[tagName].parents; -} */ +} /** @returns child `tagName`s for SCL (2007B4) element tag */ -/** export function childTags(tagName: string): string[] { +/* export function childTags(tagName: string): string[] { if (!isSCLTag(tagName)) return []; return tags[tagName].children; diff --git a/utils/validScl.ts b/utils/validScl.ts index 3eec09d..5c3831f 100644 --- a/utils/validScl.ts +++ b/utils/validScl.ts @@ -16,9 +16,11 @@ export const validScl = ` + + @@ -62,7 +64,7 @@ export const validScl = `

0010

- +

RJ45

@@ -93,8 +95,12 @@ export const validScl = `
+ +

01-0C-CD-04-00-20

+

01-0C-CD-04-00-20

007

+

01-0C-CD-04-00-20

4

4002

@@ -132,9 +138,18 @@ export const validScl = ` + + textContent + IED2 IED2 + IED3 + IED4 + IED1 + IED2 + IED3 + IED5 @@ -163,7 +178,12 @@ export const validScl = ` - 1 + + 1 + + 1 + 2 + 3 @@ -183,9 +203,13 @@ export const validScl = ` - - - + + + + + + + @@ -213,7 +237,8 @@ export const validScl = ` - + + @@ -269,7 +294,8 @@ export const validScl = ` - + + @@ -280,6 +306,7 @@ export const validScl = ` + @@ -300,7 +327,7 @@ export const validScl = ` - + status-only @@ -327,8 +354,40 @@ export const validScl = ` + + + + + + + + 4 + + + + + + + + + 4 + + + + + + + + + 4 + + + + + + @@ -469,6 +528,17 @@ export const validScl = ` + + + + + + + + + + + @@ -623,6 +693,7 @@ export const validScl = ` + IEC 61850-8-1:2003 @@ -631,6 +702,7 @@ export const validScl = ` + IEC 61850-8-2:2021