diff --git a/foundation.ts b/foundation.ts new file mode 100644 index 0000000..8e02c09 --- /dev/null +++ b/foundation.ts @@ -0,0 +1,93 @@ +import { css } from 'lit'; +import { identity, find } from '@openenergytools/scl-lib'; + +export function updateElementReference( + newDoc: XMLDocument, + oldElement: Element +): Element | null { + if (!oldElement || !oldElement.closest('SCL')) return null; + + const id = identity(oldElement); + const newElement = find(newDoc, oldElement.tagName, id); + + return newElement; +} + +export function isPublic(element: Element): boolean { + return !element.closest('Private'); +} + +export const styles = css` + .content { + display: flex; + height: calc(100vh - 184px); + } + + .selectionlist { + flex: 35%; + margin: 4px 4px 4px 8px; + background-color: var(--mdc-theme-surface); + overflow-y: scroll; + } + + .elementeditorcontainer { + flex: 65%; + margin: 4px 8px 4px 4px; + background-color: var(--mdc-theme-surface); + overflow-y: scroll; + display: flex; + } + + .listitem.header { + font-weight: 500; + } + + mwc-list-item.hidden[noninteractive] + li[divider] { + display: none; + } + + mwc-button { + display: none; + } + + @media (max-width: 950px) { + .elementeditorcontainer { + display: block; + } + } + + @media (max-width: 599px) { + .content { + height: 100%; + } + + .selectionlist { + position: absolute; + width: calc(100% - 32px); + height: auto; + top: 110px; + left: 8px; + background-color: var(--mdc-theme-surface); + z-index: 1; + box-shadow: 0 8px 10px 1px rgba(0, 0, 0, 0.14), + 0 3px 14px 2px rgba(0, 0, 0, 0.12), 0 5px 5px -3px rgba(0, 0, 0, 0.2); + } + + .elementeditorcontainer { + display: block; + } + + data-set-element-editor { + width: calc(100% - 16px); + } + + .selectionlist.hidden { + display: none; + } + + mwc-button { + display: flex; + margin: 4px 8px 8px; + } + } +`; diff --git a/foundation/foundation.ts b/foundation/foundation.ts new file mode 100644 index 0000000..7f9fe2d --- /dev/null +++ b/foundation/foundation.ts @@ -0,0 +1,113 @@ +import { findFCDAs, isSubscribed } from './subscription/subscription.js'; + +/** + * Extract the 'name' attribute from the given XML element. + * @param element - The element to extract name from. + * @returns the name, or undefined if there is no name. + */ +export function getNameAttribute(element: Element): string | undefined { + const name = element.getAttribute('name'); + return name || undefined; +} + +/** + * Extract the 'desc' attribute from the given XML element. + * @param element - The element to extract description from. + * @returns the name, or undefined if there is no description. + */ +export function getDescriptionAttribute(element: Element): string | undefined { + const name = element.getAttribute('desc'); + return name || undefined; +} + +/** Sorts selected `ListItem`s to the top and disabled ones to the bottom. */ +export function compareNames(a: Element | string, b: Element | string): number { + if (typeof a === 'string' && typeof b === 'string') return a.localeCompare(b); + + if (typeof a === 'object' && typeof b === 'string') + return (a.getAttribute('name') ?? '').localeCompare(b); + + if (typeof a === 'string' && typeof b === 'object') + return a.localeCompare(b.getAttribute('name')!); + + if (typeof a === 'object' && typeof b === 'object') + return (a.getAttribute('name') ?? '').localeCompare( + b.getAttribute('name') ?? '' + ); + + return 0; +} + +const serviceTypeControlBlockTags: Partial> = { + GOOSE: ['GSEControl'], + SMV: ['SampledValueControl'], + Report: ['ReportControl'], + NONE: ['LogControl', 'GSEControl', 'SampledValueControl', 'ReportControl'], +}; + +// NOTE: This function modified extensively from the core function to more efficiently handle the srcXXX attributes on ExtRefs +export function findControlBlocks( + extRef: Element, + controlType: 'GOOSE' | 'SMV' +): Element[] { + if (!isSubscribed(extRef)) return []; + + const extRefValues = ['iedName', 'srcPrefix', 'srcCBName', 'srcLNInst']; + const [srcIedName, srcPrefix, srcCBName, srcLNInst] = extRefValues.map( + attr => extRef.getAttribute(attr) ?? '' + ); + + const srcLDInst = + extRef.getAttribute('srcLDInst') ?? extRef.getAttribute('ldInst'); + const srcLNClass = extRef.getAttribute('srcLNClass') ?? 'LLN0'; + + const controlBlockFromSrc = Array.from( + extRef + .closest('SCL')! + .querySelectorAll( + `IED[name="${srcIedName}"] LDevice[inst="${srcLDInst}"] > LN0[lnClass="${srcLNClass}"]${ + srcPrefix !== '' ? `[prefix="${srcPrefix}"]` : '' + }${srcLNInst !== '' ? `[inst="${srcLNInst}"]` : ''} > ${ + serviceTypeControlBlockTags[controlType] + }[name="${srcCBName}"]` + ) + ); + + if (controlBlockFromSrc) return controlBlockFromSrc; + + // Ed 1 this is more complicated as control blocks not explicitly identified... + + const fcdas = findFCDAs(extRef); + const cbTags = + serviceTypeControlBlockTags[extRef.getAttribute('serviceType') ?? 'NONE'] ?? + []; + const controlBlocks = new Set( + fcdas.flatMap(fcda => { + const dataSet = fcda.parentElement!; + const dsName = dataSet.getAttribute('name') ?? ''; + const anyLN = dataSet.parentElement!; + return cbTags + .flatMap(tag => Array.from(anyLN.getElementsByTagName(tag))) + .filter(cb => cb.getAttribute('datSet') === dsName); + }) + ); + return Array.from(controlBlocks); +} + +/** maximum value for `lnInst` attribute */ +const maxLnInst = 99; +const lnInstRange = Array(maxLnInst) + .fill(1) + .map((_, i) => `${i + 1}`); + +/** + * @param lnElements - The LN elements to be scanned for `inst` + * values already in use. + * @returns first available inst value for LN or undefined if no inst is available + */ +export function minAvailableLogicalNodeInstance( + lnElements: Element[] +): string | undefined { + const lnInsts = new Set(lnElements.map(ln => ln.getAttribute('inst') || '')); + return lnInstRange.find(lnInst => !lnInsts.has(lnInst)); +} diff --git a/foundation/icons.ts b/foundation/icons.ts index da4e419..52467c0 100644 --- a/foundation/icons.ts +++ b/foundation/icons.ts @@ -10,7 +10,7 @@ export const smvIcon = svg` `${i + 1}`); - -/** - * @param lnElements - The LN elements to be scanned for `inst` - * values already in use. - * @returns first available inst value for LN or undefined if no inst is available - */ -function minAvailableLogicalNodeInstance( - lnElements: Element[] -): string | undefined { - const lnInsts = new Set(lnElements.map(ln => ln.getAttribute('inst') || '')); - return lnInstRange.find(lnInst => !lnInsts.has(lnInst)); -} - -// TODO: Replace with `tControl/controlBlockObjRef` in scl-lib -// See https://github.com/OpenEnergyTools/scl-lib/issues/73 - -/** - * Creates a string pointer to the control block element. - * - * @param controlBlock The GOOSE or SMV message element - * @returns null if the control block is undefined or a string pointer to the control block element - */ -export function controlBlockReference( - controlBlock: Element | undefined -): string | null { - if (!controlBlock) return null; - const anyLn = controlBlock.closest('LN,LN0'); - const prefix = anyLn?.getAttribute('prefix') ?? ''; - const lnClass = anyLn?.getAttribute('lnClass'); - const lnInst = anyLn?.getAttribute('inst') ?? ''; - const ldInst = controlBlock.closest('LDevice')?.getAttribute('inst'); - const iedName = controlBlock.closest('IED')?.getAttribute('name'); - const cbName = controlBlock.getAttribute('name'); - if (!cbName && !iedName && !ldInst && !lnClass) return null; - return `${iedName}${ldInst}/${prefix}${lnClass}${lnInst}.${cbName}`; -} - -/** - * Searches for first instantiated LGOS/LSVS LN for presence of DOI>DAI[valKind=Conf/RO][valImport=true] - * given a supervision type and if necessary then searches DataTypeTemplates for - * DOType>DA[valKind=Conf/RO][valImport=true] to determine if modifications to supervision are allowed. - * @param ied - SCL IED element. - * @param supervisionType - either 'LGOS' or 'LSVS' supervision LN classes. - * @returns boolean indicating if subscriptions are allowed. - */ -export function isSupervisionModificationAllowed( - ied: Element, - supervisionType: string -): boolean { - const firstSupervisionLN = ied.querySelector( - `LN[lnClass="${supervisionType}"]` - ); - - // no supervision logical nodes => no new supervision possible - if (firstSupervisionLN === null) return false; - - // check if allowed to modify based on first instance properties - const supervisionName = supervisionType === 'LGOS' ? 'GoCBRef' : 'SvCBRef'; - const instValKind = firstSupervisionLN! - .querySelector(`DOI[name="${supervisionName}"]>DAI[name="setSrcRef"]`) - ?.getAttribute('valKind'); - const instValImport = firstSupervisionLN! - .querySelector(`DOI[name="${supervisionName}"]>DAI[name="setSrcRef"]`) - ?.getAttribute('valImport'); - - if ( - (instValKind === 'RO' || instValKind === 'Conf') && - instValImport === 'true' - ) - return true; - - // check if allowed to modify based on DataTypeTemplates for first instance - const rootNode = firstSupervisionLN?.ownerDocument; - const lNodeType = firstSupervisionLN.getAttribute('lnType'); - const lnClass = firstSupervisionLN.getAttribute('lnClass'); - const dObj = rootNode.querySelector( - `DataTypeTemplates > LNodeType[id="${lNodeType}"][lnClass="${lnClass}"] > DO[name="${ - lnClass === 'LGOS' ? 'GoCBRef' : 'SvCBRef' - }"]` - ); - if (dObj) { - const dORef = dObj.getAttribute('type'); - const daObj = rootNode.querySelector( - `DataTypeTemplates > DOType[id="${dORef}"] > DA[name="setSrcRef"]` - ); - if (daObj) { - return ( - (daObj.getAttribute('valKind') === 'Conf' || - daObj.getAttribute('valKind') === 'RO') && - daObj.getAttribute('valImport') === 'true' - ); - } - } - // definition missing - return false; -} - -// NOTE: Have removed the LN0 selector here. - -/** - * Return Val elements within an LGOS/LSVS instance for a particular IED and control block type. - * @param ied - IED SCL element. - * @param cbTagName - Either GSEControl or (defaults to) SampledValueControl. - * @returns an Element array of Val SCL elements within an LGOS/LSVS node. - */ -function getSupervisionCbRefs(ied: Element, cbTagName: string): Element[] { - const supervisionType = cbTagName === 'GSEControl' ? 'LGOS' : 'LSVS'; - const supervisionName = supervisionType === 'LGOS' ? 'GoCBRef' : 'SvCBRef'; - const selectorString = `LN[lnClass="${supervisionType}"]>DOI[name="${supervisionName}"]>DAI[name="setSrcRef"]>Val`; - return Array.from(ied.querySelectorAll(selectorString)); -} - -/** - * Return Edit actions to remove supervision by clearing the Val element. - * - * @param controlBlock The GOOSE or SMV message element - * @param subscriberIED The subscriber IED - * @returns an empty array if removing the supervision is not possible or an array - * with a Delete/Insert action to update the Val element. - */ -export function removeSubscriptionSupervision( - controlBlock: Element | undefined, - subscriberIED: Element | undefined -): Edit[] { - if (!controlBlock || !subscriberIED) return []; - const valElement = getSupervisionCbRefs( - subscriberIED, - controlBlock.tagName - ).find(val => val.textContent === controlBlockReference(controlBlock)); - if (!valElement) return []; - - const daiElement = valElement.closest('DAI'); - - const edits = []; - - // remove old element - edits.push({ - node: valElement, - }); - - const newValElement = valElement.cloneNode(true); - newValElement.textContent = ''; - - // add new element - edits.push({ - parent: daiElement!, - reference: null, - node: newValElement, - }); - - return edits; -} - -// TODO: Daniel has changed this function -/** - * Counts the max number of LN instances with supervision allowed for - * the given control block's type of message. - * - * @param subscriberIED The subscriber IED - * @param controlBlockType The GOOSE or SMV message element - * @returns The max number of LN instances with supervision allowed - */ -export function maxSupervisions( - subscriberIED: Element, - controlBlockType: string -): number { - const maxAttr = controlBlockType === 'GSEControl' ? 'maxGo' : 'maxSv'; - const maxValues = parseInt( - subscriberIED - .querySelector('Services>SupSubscription') - ?.getAttribute(maxAttr) ?? '0', - 10 - ); - return Number.isNaN(maxValues) ? 0 : maxValues; -} - -/** Returns an new LN instance available for supervision instantiation - * - * @param controlBlock The GOOSE or SMV message element - * @param subscriberIED The subscriber IED - * @returns The LN instance or null if no LN instance could be found or created - */ -export function createNewSupervisionLnInst( - subscriberIED: Element, - supervisionType: string -): Element | null { - const newLN = subscriberIED.ownerDocument.createElementNS( - SCL_NAMESPACE, - 'LN' - ); - const openScdTag = subscriberIED.ownerDocument.createElementNS( - SCL_NAMESPACE, - 'Private' - ); - openScdTag.setAttribute('type', 'OpenSCD.create'); - newLN.appendChild(openScdTag); - newLN.setAttribute('lnClass', supervisionType); - - // TODO: Why is this here? getSupervisionCbRefs is not of use. - // Have adjusted to just find the first supervision, no need for it to be instantiated. - // To create a new LGOS/LSVS LN there should be no need for a Val element to exist somewhere else. - // const supervisionName = supervisionType === 'LGOS' ? 'GoCBRef' : 'SvCBRef'; - - const selectorString = `LN[lnClass="${supervisionType}"],LN0[lnClass="${supervisionType}"]`; - - // TOD: Think as to why this removed portion might be needed... - // >DOI[name="${supervisionName}"]>DAI[name="setSrcRef"]>Val, - // LN0[lnClass="${supervisionType}"]>DOI[name="${supervisionName}"]>DAI[name="setSrcRef"]>Val`; - - const firstSiblingSupervisionLN = Array.from( - subscriberIED.querySelectorAll(selectorString) - )[0]; - - if (!firstSiblingSupervisionLN) return null; - newLN.setAttribute( - 'lnType', - firstSiblingSupervisionLN?.getAttribute('lnType') ?? '' - ); - - /* Before we return, we make sure that LN's inst is unique, non-empty - and also the minimum inst as the minimum of all available in the IED */ - const inst = newLN.getAttribute('inst') ?? ''; - if (inst === '') { - const instNumber = minAvailableLogicalNodeInstance( - Array.from( - subscriberIED.querySelectorAll(`LN[lnClass="${supervisionType}"]`) - ) - ); - if (!instNumber) return null; - newLN.setAttribute('inst', instNumber); - } - return newLN; -} - -/** Returns an new LN instance available for supervision instantiation - * - * @param controlBlock The GOOSE or SMV message element - * @param subscriberIED The subscriber IED - * @returns The LN instance or null if no LN instance could be found or created - */ -export function createNewSupervisionLnEvent( - ied: Element, - supervisionType: 'LGOS' | 'LSVS' -): Edit | null { - const newLN = createNewSupervisionLnInst(ied, supervisionType); - - if (!newLN) return null; - - const parent = ied.querySelector( - `LN[lnClass="${supervisionType}"]` - )?.parentElement; - - if (parent && newLN) { - const edit = { - parent, - node: newLN, - reference: - parent!.querySelector(`LN[lnClass="${supervisionType}"]:last-child`) - ?.nextElementSibling ?? null, - }; - - return edit; - } - return null; -} diff --git a/foundation/subscription/subscription.ts b/foundation/subscription/subscription.ts new file mode 100644 index 0000000..7140fd0 --- /dev/null +++ b/foundation/subscription/subscription.ts @@ -0,0 +1,654 @@ +import { Edit, Insert, Remove } from '@openscd/open-scd-core'; + +import { minAvailableLogicalNodeInstance } from '../foundation.js'; + +export const SCL_NAMESPACE = 'http://www.iec.ch/61850/2003/SCL'; + +/** + * Simple function to check if the attribute of the Left Side has the same value as the attribute of the Right Element. + * + * @param leftElement - The Left Element to check against. + * @param rightElement - The Right Element to check. + * @param attributeName - The name of the attribute to check. + */ +export function sameAttributeValue( + leftElement: Element | undefined, + rightElement: Element | undefined, + attributeName: string +): boolean { + return ( + (leftElement?.getAttribute(attributeName) ?? '') === + (rightElement?.getAttribute(attributeName) ?? '') + ); +} + +/** + * Simple function to check if the attribute of the Left Side has the same value as the attribute of the Right Element. + * + * @param leftElement - The Left Element to check against. + * @param leftAttributeName - The name of the attribute (left) to check against. + * @param rightElement - The Right Element to check. + * @param rightAttributeName - The name of the attribute (right) to check. + */ +export function sameAttributeValueDiffName( + leftElement: Element | undefined, + leftAttributeName: string, + rightElement: Element | undefined, + rightAttributeName: string +): boolean { + return ( + (leftElement?.getAttribute(leftAttributeName) ?? '') === + (rightElement?.getAttribute(rightAttributeName) ?? '') + ); +} + +export type SclEdition = '2003' | '2007B' | '2007B4'; +export function getSclSchemaVersion(doc: Document): SclEdition { + const scl: Element = doc.documentElement; + const edition = + (scl.getAttribute('version') ?? '2003') + + (scl.getAttribute('revision') ?? '') + + (scl.getAttribute('release') ?? ''); + return edition; +} + +export const serviceTypes: Partial> = { + ReportControl: 'Report', + GSEControl: 'GOOSE', + SampledValueControl: 'SMV', +}; + +/** + * If needed check version specific attributes against FCDA Element. + * + * @param controlTagName - Indicates which type of control element. + * @param controlElement - The Control Element to check against. + * @param extRefElement - The Ext Ref Element to check. + */ +function checkEditionSpecificRequirements( + controlTagName: 'SampledValueControl' | 'GSEControl', + controlElement: Element | undefined, + extRefElement: Element +): boolean { + // For 2003 Edition no extra check needed. + if (getSclSchemaVersion(extRefElement.ownerDocument) === '2003') { + return true; + } + + const lDeviceElement = controlElement?.closest('LDevice') ?? undefined; + const lnElement = controlElement?.closest('LN0') ?? undefined; + + // For the 2007B and 2007B4 Edition we need to check some extra attributes. + return ( + (extRefElement.getAttribute('serviceType') ?? '') === + serviceTypes[controlTagName] && + sameAttributeValueDiffName( + extRefElement, + 'srcLDInst', + lDeviceElement, + 'inst' + ) && + sameAttributeValueDiffName( + extRefElement, + 'srcPrefix', + lnElement, + 'prefix' + ) && + sameAttributeValueDiffName( + extRefElement, + 'srcLNClass', + lnElement, + 'lnClass' + ) && + sameAttributeValueDiffName(extRefElement, 'srcLNInst', lnElement, 'inst') && + sameAttributeValueDiffName( + extRefElement, + 'srcCBName', + controlElement, + 'name' + ) + ); +} + +/** + * Check if the ExtRef is already subscribed to a FCDA Element. + * + * @param extRefElement - The Ext Ref Element to check. + */ +export function isSubscribed(extRefElement: Element): boolean { + return ( + extRefElement.hasAttribute('iedName') && + extRefElement.hasAttribute('ldInst') && + extRefElement.hasAttribute('lnClass') && + extRefElement.hasAttribute('lnInst') && + extRefElement.hasAttribute('doName') && + extRefElement.hasAttribute('daName') + ); +} + +/** + * Check if specific attributes from the ExtRef Element are the same as the ones from the FCDA Element + * and also if the IED Name is the same. If that is the case this ExtRef subscribes to the selected FCDA + * Element. + * + * @param controlTagName - Indicates which type of control element. + * @param controlElement - The Control Element to check against. + * @param fcdaElement - The FCDA Element to check against. + * @param extRefElement - The Ext Ref Element to check. + */ +export function isSubscribedTo( + controlTagName: 'SampledValueControl' | 'GSEControl', + controlElement: Element | undefined, + fcdaElement: Element | undefined, + extRefElement: Element +): boolean { + return ( + extRefElement.getAttribute('iedName') === + fcdaElement?.closest('IED')?.getAttribute('name') && + sameAttributeValue(fcdaElement, extRefElement, 'ldInst') && + sameAttributeValue(fcdaElement, extRefElement, 'prefix') && + sameAttributeValue(fcdaElement, extRefElement, 'lnClass') && + sameAttributeValue(fcdaElement, extRefElement, 'lnInst') && + sameAttributeValue(fcdaElement, extRefElement, 'doName') && + sameAttributeValue(fcdaElement, extRefElement, 'daName') && + checkEditionSpecificRequirements( + controlTagName, + controlElement, + extRefElement + ) + ); +} + +/** + * Creates a string pointer to the control block element. + * + * @param controlBlock The GOOSE or SMV message element + * @returns null if the control block is undefined or a string pointer to the control block element + */ +export function controlBlockReference( + controlBlock: Element | undefined +): string | null { + if (!controlBlock) return null; + const anyLn = controlBlock.closest('LN,LN0'); + const prefix = anyLn?.getAttribute('prefix') ?? ''; + const lnClass = anyLn?.getAttribute('lnClass'); + const lnInst = anyLn?.getAttribute('inst') ?? ''; + const ldInst = controlBlock.closest('LDevice')?.getAttribute('inst'); + const iedName = controlBlock.closest('IED')?.getAttribute('name'); + const cbName = controlBlock.getAttribute('name'); + if (!cbName && !iedName && !ldInst && !lnClass) return null; + return `${iedName}${ldInst}/${prefix}${lnClass}${lnInst}.${cbName}`; +} + +export function findFCDAs(extRef: Element): Element[] { + if (extRef.tagName !== 'ExtRef' || extRef.closest('Private')) return []; + + const [iedName, ldInst, prefix, lnClass, lnInst, doName, daName] = [ + 'iedName', + 'ldInst', + 'prefix', + 'lnClass', + 'lnInst', + 'doName', + 'daName', + ].map(name => extRef.getAttribute(name)); + const ied = Array.from(extRef.ownerDocument.getElementsByTagName('IED')).find( + element => + element.getAttribute('name') === iedName && !element.closest('Private') + ); + if (!ied) return []; + + return Array.from(ied.getElementsByTagName('FCDA')) + .filter(item => !item.closest('Private')) + .filter( + fcda => + (fcda.getAttribute('ldInst') ?? '') === (ldInst ?? '') && + (fcda.getAttribute('prefix') ?? '') === (prefix ?? '') && + (fcda.getAttribute('lnClass') ?? '') === (lnClass ?? '') && + (fcda.getAttribute('lnInst') ?? '') === (lnInst ?? '') && + (fcda.getAttribute('doName') ?? '') === (doName ?? '') && + (fcda.getAttribute('daName') ?? '') === (daName ?? '') + ); +} + +/** + * Searches for first instantiated LGOS/LSVS LN for presence of DOI>DAI[valKind=Conf/RO][valImport=true] + * given a supervision type and if necessary then searches DataTypeTemplates for + * DOType>DA[valKind=Conf/RO][valImport=true] to determine if modifications to supervision are allowed. + * @param ied - SCL IED element. + * @param supervisionType - either 'LGOS' or 'LSVS' supervision LN classes. + * @returns boolean indicating if subscriptions are allowed. + */ +export function isSupervisionModificationAllowed( + ied: Element, + supervisionType: string +): boolean { + const firstSupervisionLN = ied.querySelector( + `LN[lnClass="${supervisionType}"]` + ); + + // no supervision logical nodes => no new supervision possible + if (firstSupervisionLN === null) return false; + + // check if allowed to modify based on first instance properties + const supervisionName = supervisionType === 'LGOS' ? 'GoCBRef' : 'SvCBRef'; + const instValKind = firstSupervisionLN! + .querySelector(`DOI[name="${supervisionName}"]>DAI[name="setSrcRef"]`) + ?.getAttribute('valKind'); + const instValImport = firstSupervisionLN! + .querySelector(`DOI[name="${supervisionName}"]>DAI[name="setSrcRef"]`) + ?.getAttribute('valImport'); + + if ( + (instValKind === 'RO' || instValKind === 'Conf') && + instValImport === 'true' + ) + return true; + + // check if allowed to modify based on DataTypeTemplates for first instance + const rootNode = firstSupervisionLN?.ownerDocument; + const lNodeType = firstSupervisionLN.getAttribute('lnType'); + const lnClass = firstSupervisionLN.getAttribute('lnClass'); + const dObj = rootNode.querySelector( + `DataTypeTemplates > LNodeType[id="${lNodeType}"][lnClass="${lnClass}"] > DO[name="${ + lnClass === 'LGOS' ? 'GoCBRef' : 'SvCBRef' + }"]` + ); + if (dObj) { + const dORef = dObj.getAttribute('type'); + const daObj = rootNode.querySelector( + `DataTypeTemplates > DOType[id="${dORef}"] > DA[name="setSrcRef"]` + ); + if (daObj) { + return ( + (daObj.getAttribute('valKind') === 'Conf' || + daObj.getAttribute('valKind') === 'RO') && + daObj.getAttribute('valImport') === 'true' + ); + } + } + // definition missing + return false; +} + +// NOTE: Have removed the LN0 selector here. + +/** + * Return Val elements within an LGOS/LSVS instance for a particular IED and control block type. + * @param ied - IED SCL element. + * @param cbTagName - Either GSEControl or (defaults to) SampledValueControl. + * @returns an Element array of Val SCL elements within an LGOS/LSVS node. + */ +function getSupervisionCbRefs(ied: Element, cbTagName: string): Element[] { + const supervisionType = cbTagName === 'GSEControl' ? 'LGOS' : 'LSVS'; + const supervisionName = supervisionType === 'LGOS' ? 'GoCBRef' : 'SvCBRef'; + const selectorString = `LN[lnClass="${supervisionType}"]>DOI[name="${supervisionName}"]>DAI[name="setSrcRef"]>Val`; + return Array.from(ied.querySelectorAll(selectorString)); +} + +/** + * Return an array with a single Remove action to delete the supervision element + * for the given GOOSE/SMV message and subscriber IED. + * + * @param controlBlock The GOOSE or SMV message element + * @param subscriberIED The subscriber IED + * @returns an empty array if removing the supervision is not possible or an array + * with a single Delete action that removes the LN if it was created in OpenSCD + * or only the supervision structure DOI/DAI/Val if it was created by the user. + */ +export function removeSubscriptionSupervision( + controlBlock: Element | undefined, + subscriberIED: Element | undefined +): Edit[] { + if (!controlBlock || !subscriberIED) return []; + const valElement = getSupervisionCbRefs( + subscriberIED, + controlBlock.tagName + ).find(val => val.textContent === controlBlockReference(controlBlock)); + if (!valElement) return []; + + const daiElement = valElement.closest('DAI'); + + const edits = []; + + // remove old element + edits.push({ + node: valElement, + }); + + const newValElement = valElement.cloneNode(true); + newValElement.textContent = ''; + + // add new element + edits.push({ + parent: daiElement!, + reference: null, + node: newValElement, + }); + + return edits; +} + +// TODO: Daniel has changed this function +/** + * Counts the max number of LN instances with supervision allowed for + * the given control block's type of message. + * + * @param subscriberIED The subscriber IED + * @param controlBlockType The GOOSE or SMV message element + * @returns The max number of LN instances with supervision allowed + */ +export function maxSupervisions( + subscriberIED: Element, + controlBlockType: string +): number { + const maxAttr = controlBlockType === 'GSEControl' ? 'maxGo' : 'maxSv'; + const maxValues = parseInt( + subscriberIED + .querySelector('Services>SupSubscription') + ?.getAttribute(maxAttr) ?? '0', + 10 + ); + return Number.isNaN(maxValues) ? 0 : maxValues; +} + +/** + * Counts the number of LN instances with proper supervision for the given control block set up. + * + * @param subscriberIED - The subscriber IED. + * @param controlBlock - The GOOSE or SMV message element. + * @returns The number of LN instances with a supervision set up. + */ +function instantiatedSupervisionsCount( + subscriberIED: Element, + controlBlock: Element +): number { + const instantiatedValues = getSupervisionCbRefs( + subscriberIED, + controlBlock.tagName + ).filter(val => val.textContent !== ''); + return instantiatedValues.length; +} + +/** + * Checks if the given combination of GOOSE/SMV message and subscriber IED + * allows for subscription supervision. + * @param controlBlock The GOOSE or SMV message element + * @param subscriberIED The subscriber IED + * @param supervisionType LSVS or LGOS + * @returns true if both controlBlock and subscriberIED meet the requirements for + * setting up a supervision for the specified supervision type or false if they don't + */ +function isSupervisionAllowed( + controlBlock: Element, + subscriberIED: Element, + supervisionType: string +): boolean { + if (getSclSchemaVersion(subscriberIED.ownerDocument) === '2003') return false; + if (subscriberIED.querySelector(`LN[lnClass="${supervisionType}"]`) === null) + return false; + if ( + getSupervisionCbRefs(subscriberIED, controlBlock.tagName).find( + val => val.textContent === controlBlockReference(controlBlock) + ) + ) + return false; + if ( + maxSupervisions(subscriberIED, controlBlock.tagName) <= + instantiatedSupervisionsCount(subscriberIED, controlBlock) + ) + return false; + + return true; +} + +/** Returns an new LN instance available for supervision instantiation + * + * @param controlBlock The GOOSE or SMV message element + * @param subscriberIED The subscriber IED + * @returns The LN instance or null if no LN instance could be found or created + */ +export function createNewSupervisionLnInst( + subscriberIED: Element, + supervisionType: string +): Element | null { + const newLN = subscriberIED.ownerDocument.createElementNS( + SCL_NAMESPACE, + 'LN' + ); + const openScdTag = subscriberIED.ownerDocument.createElementNS( + SCL_NAMESPACE, + 'Private' + ); + openScdTag.setAttribute('type', 'OpenSCD.create'); + newLN.appendChild(openScdTag); + newLN.setAttribute('lnClass', supervisionType); + + // TODO: Why is this here? getSupervisionCbRefs is not of use. + // Have adjusted to just find the first supervision, no need for it to be instantiated. + // To create a new LGOS/LSVS LN there should be no need for a Val element to exist somewhere else. + // const supervisionName = supervisionType === 'LGOS' ? 'GoCBRef' : 'SvCBRef'; + + const selectorString = `LN[lnClass="${supervisionType}"],LN0[lnClass="${supervisionType}"]`; + + // TOD: Think as to why this removed portion might be needed... + // >DOI[name="${supervisionName}"]>DAI[name="setSrcRef"]>Val, + // LN0[lnClass="${supervisionType}"]>DOI[name="${supervisionName}"]>DAI[name="setSrcRef"]>Val`; + + const firstSiblingSupervisionLN = Array.from( + subscriberIED.querySelectorAll(selectorString) + )[0]; + + if (!firstSiblingSupervisionLN) return null; + newLN.setAttribute( + 'lnType', + firstSiblingSupervisionLN?.getAttribute('lnType') ?? '' + ); + + /* Before we return, we make sure that LN's inst is unique, non-empty + and also the minimum inst as the minimum of all available in the IED */ + const inst = newLN.getAttribute('inst') ?? ''; + if (inst === '') { + const instNumber = minAvailableLogicalNodeInstance( + Array.from( + subscriberIED.querySelectorAll(`LN[lnClass="${supervisionType}"]`) + ) + ); + if (!instNumber) return null; + newLN.setAttribute('inst', instNumber); + } + return newLN; +} + +/** Returns an new LN instance available for supervision instantiation + * + * @param controlBlock The GOOSE or SMV message element + * @param subscriberIED The subscriber IED + * @returns The LN instance or null if no LN instance could be found or created + */ +export function createNewSupervisionLnEvent( + ied: Element, + supervisionType: 'LGOS' | 'LSVS' +): Edit | null { + const newLN = createNewSupervisionLnInst(ied, supervisionType); + + if (!newLN) return null; + + const parent = ied.querySelector( + `LN[lnClass="${supervisionType}"]` + )?.parentElement; + + if (parent && newLN) { + const edit = { + parent, + node: newLN, + reference: + parent!.querySelector(`LN[lnClass="${supervisionType}"]:last-child`) + ?.nextElementSibling ?? null, + }; + + return edit; + } + return null; +} + +/* TODO: Update and add proper unit tests, needs to be changed for subscriber plugin */ + +/** Returns an new or existing LN instance available for supervision instantiation + * + * @param controlBlock The GOOSE or SMV message element + * @param subscriberIED The subscriber IED + * @returns The LN instance or null if no LN instance could be found or created + */ +export function findOrCreateAvailableLNInst( + controlBlock: Element, + subscriberIED: Element, + supervisionType: string +): Element | null { + let availableLN = + Array.from( + subscriberIED.querySelectorAll(`LN[lnClass="${supervisionType}"]`) + ).find(ln => { + const supervisionName = + supervisionType === 'LGOS' ? 'GoCBRef' : 'SvCBRef'; + // TODO: What about overriding incorrect values? Do we need an edit to do manual fixes? + return ( + ln.querySelector( + `DOI[name="${supervisionName}"]>DAI[name="setSrcRef"]>Val` + ) === null || + ln.querySelector( + `DOI[name="${supervisionName}"]>DAI[name="setSrcRef"]>Val` + )?.textContent === '' + ); + }) ?? null; + + if (!availableLN) { + availableLN = createNewSupervisionLnInst(subscriberIED, supervisionType); + } + + return availableLN; +} + +// IMPORTANT: Has been updated by Daniel to add existingSupervision +// TODO: But does it correctly check that modifications are allowed? Probably. + +/** + * Returns an array with a single Insert Edit to create a new + * supervision element for the given GOOSE/SMV message and subscriber IED. + * + * @param controlBlock The GOOSE or SMV message element + * @param subscriberIED The subscriber IED + * @returns an empty array if instantiation is not possible or an array with a single Create action + */ +export function instantiateSubscriptionSupervision( + controlBlock: Element | undefined, + subscriberIED: Element | undefined, + existingSupervision: Element | undefined = undefined +): (Insert | Remove)[] { + const supervisionType = + controlBlock?.tagName === 'GSEControl' ? 'LGOS' : 'LSVS'; + if ( + !controlBlock || + !subscriberIED || + !isSupervisionAllowed(controlBlock, subscriberIED, supervisionType) + ) + return []; + const availableLN = + existingSupervision ?? + findOrCreateAvailableLNInst(controlBlock, subscriberIED, supervisionType); + if ( + !availableLN || + !isSupervisionModificationAllowed(subscriberIED, supervisionType) + ) + return []; + + const edits: (Insert | Remove)[] = []; + // If creating new LN element + if (!availableLN.parentElement) { + const parent = subscriberIED.querySelector( + `LN[lnClass="${supervisionType}"]` + )?.parentElement; + if (parent) { + // use Insert edit for supervision LN + edits.push({ + parent, + node: availableLN, + reference: + parent!.querySelector(`LN[lnClass="${supervisionType}"]:last-child`) + ?.nextElementSibling ?? null, + }); + } + } + + // Insert child elements + const supervisionName = supervisionType === 'LGOS' ? 'GoCBRef' : 'SvCBRef'; + + let doiElement = availableLN.querySelector(`DOI[name="${supervisionName}"]`); + if (!doiElement) { + doiElement = subscriberIED.ownerDocument.createElementNS( + SCL_NAMESPACE, + 'DOI' + ); + doiElement.setAttribute('name', supervisionName); + edits.push({ + parent: availableLN!, + reference: null, + node: doiElement, + }); + } + + let daiElement = availableLN.querySelector( + `DOI[name="${supervisionName}"]>DAI[name="setSrcRef"]` + ); + if (!daiElement) { + daiElement = subscriberIED.ownerDocument.createElementNS( + SCL_NAMESPACE, + 'DAI' + ); + const srcValRef = subscriberIED.querySelector( + `LN[lnClass="${supervisionType}"]>DOI[name="${supervisionName}"]>DAI[name="setSrcRef"]` + ); + daiElement.setAttribute('name', 'setSrcRef'); + + // transfer valKind and valImport from first supervision instance if present + if (srcValRef?.hasAttribute('valKind')) + daiElement.setAttribute('valKind', srcValRef.getAttribute('valKind')!); + if (srcValRef?.hasAttribute('valImport')) + daiElement.setAttribute( + 'valImport', + srcValRef.getAttribute('valImport')! + ); + edits.push({ + parent: doiElement!, + reference: null, + node: daiElement, + }); + } + + const valTextContent = controlBlockReference(controlBlock); + const valElement = daiElement.querySelector(`Val`); + let newValElement: Element; + + if (valElement) { + // remove old element + edits.push({ + node: valElement, + }); + newValElement = valElement.cloneNode(true); + } else { + newValElement = subscriberIED.ownerDocument.createElementNS( + SCL_NAMESPACE, + 'Val' + ); + } + newValElement.textContent = valTextContent; + + // add new element + edits.push({ + parent: daiElement!, + reference: null, + node: newValElement, + }); + + return edits; +} diff --git a/oscd-supervision.ts b/oscd-supervision.ts index 403d6e0..ffba37c 100644 --- a/oscd-supervision.ts +++ b/oscd-supervision.ts @@ -8,13 +8,8 @@ import { } from 'lit'; import { property, query, state } from 'lit/decorators.js'; -import { - identity, - find, - instantiateSubscriptionSupervision, - sourceControlBlock, -} from '@openenergytools/scl-lib'; -import { newEditEvent, Remove } from '@openscd/open-scd-core'; +import { identity, find } from '@openenergytools/scl-lib'; +import { Edit, newEditEvent, Remove } from '@openscd/open-scd-core'; import '@material/mwc-button'; import '@material/mwc-formfield'; @@ -37,6 +32,12 @@ import './foundation/components/oscd-filter-button.js'; // import { canInstantiateSubscriptionSupervision } from '@openenergytools/scl-lib'; +import { + compareNames, + findControlBlocks, + getDescriptionAttribute, + getNameAttribute, +} from './foundation/foundation.js'; import { gooseActionIcon, smvActionIcon, @@ -45,15 +46,14 @@ import { } from './foundation/icons.js'; import { - compareNames, - getDescriptionAttribute, - getNameAttribute, controlBlockReference, - createNewSupervisionLnEvent, + createNewSupervisionLnEvent as createNewSupervisionLnEdit, + createNewSupervisionLnInst, + instantiateSubscriptionSupervision, isSupervisionModificationAllowed, maxSupervisions, removeSubscriptionSupervision, -} from './foundation/subscription.js'; +} from './foundation/subscription/subscription.js'; import type { SelectedItemsChangedEvent } from './foundation/components/oscd-filter-button.js'; @@ -210,11 +210,9 @@ function getSubscribedCBRefs( const controlBlockRefs: Set = new Set(); extRefs.forEach(extRef => { if (isGOOSEorSMV(extRef, type)) { - const cb = sourceControlBlock(extRef); - if (cb) { - const cbRef = controlBlockReference(cb); - if (cbRef) controlBlockRefs.add(cbRef); - } + findControlBlocks(extRef, type).forEach(cb => + controlBlockRefs.add(controlBlockReference(cb) ?? 'Unknown Control') + ); } }); return Array.from(controlBlockRefs); @@ -615,7 +613,6 @@ export default class Supervision extends LitElement { this.selectedIed, this.controlType )[0]; - return html` ${unused @@ -917,24 +914,17 @@ export default class Supervision extends LitElement { selectedSupervision: Element | null, newSupervision: boolean ): void { - const supervisionLN = instantiateSubscriptionSupervision( - { - subscriberIedOrLn: newSupervision - ? this.selectedIed! - : this.selectedSupervision!, - sourceControlBlock: selectedControl, - }, - { - newSupervisionLn: newSupervision, - fixedLnInst: -1, - checkEditableSrcRef: true, - checkDuplicateSupervisions: true, - checkMaxSupervisionLimits: true, - } - ); + let edits: Edit[] | undefined; - if (supervisionLN) { - this.dispatchEvent(newEditEvent(supervisionLN)); + if (newSupervision) { + this.createNewSupervision(selectedControl); + } else { + edits = instantiateSubscriptionSupervision( + selectedControl, + this.selectedIed, + selectedSupervision ?? undefined + ); + this.dispatchEvent(newEditEvent(edits)); } this.selectedIedSupervisedCBRefs = getSupervisedCBRefs( @@ -943,6 +933,46 @@ export default class Supervision extends LitElement { ); } + // TODO: restructure in terms of edits + private createNewSupervision(selectedControl: Element): void { + const subscriberIED = this.selectedIed!; + const supervisionType = + selectedControl?.tagName === 'GSEControl' ? 'LGOS' : 'LSVS'; + const newLN = createNewSupervisionLnInst(subscriberIED, supervisionType); + let edits: Edit[]; + + const parent = subscriberIED.querySelector( + `LN[lnClass="${supervisionType}"]` + )?.parentElement; + if (parent && newLN) { + // use Insert edit for supervision LN + edits = [ + { + parent, + node: newLN, + reference: + parent!.querySelector(`LN[lnClass="${supervisionType}"]:last-child`) + ?.nextElementSibling ?? null, + }, + ]; + + const instanceNum = newLN?.getAttribute('inst'); + + // TODO: Explain To The User That They Have Erred And Can't Make Any New Subscriptions! + if (edits) { + this.dispatchEvent(newEditEvent(edits)); + const instantiationEdit = instantiateSubscriptionSupervision( + selectedControl, + this.selectedIed, + parent!.querySelector( + `LN[lnClass="${supervisionType}"][inst="${instanceNum}"]` + )! + ); + this.dispatchEvent(newEditEvent(instantiationEdit)); + } + } + } + private renderUnusedControlList(): TemplateResult { return html` { if (this.selectedIed) { - const edit = createNewSupervisionLnEvent( + const edit = createNewSupervisionLnEdit( this.selectedIed, supervisionType );