Skip to content

Commit dc59736

Browse files
JakobVogelsangca-d
andauthored
feat(menu/VirtualTemplateIED): automatically create virtual IEDs (openscd#806)
* feat(vitualtemplateied/foundation): add isLeafFunction * feat(vitualtemplateied/foundation): add getNonLeafParent * feat(vitualtemplateied/foundation): add getFunctionNamingPrefix * fix(foundation): lNodeSelector for iedName None * feat(virtualtemplateied/foundation): add getUniqueFunctionName function * fix(virtualtemplateied/foundation): missing import statements * feat(virtualtemplateied/foundation): add getSpecificationIED function * feat(menu/VirtualTemplateIED): add create wizard and plugin * feat(public/plugins): add VirtualTemplateIED to plungs.js * test(menu/VirtualTemplateIED): update snapshot * refactor(menu/virtualtemplateied): change redio-button to select field * refactor(menu/virtualtemplateied): remove wizard-dialog dependancy Co-authored-by: Christian Dinkel <[email protected]>
1 parent b814c00 commit dc59736

File tree

10 files changed

+2031
-9
lines changed

10 files changed

+2031
-9
lines changed

public/js/plugins.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,15 @@ export const officialPlugins = [
112112
requireDoc: true,
113113
position: 'middle',
114114
},
115+
{
116+
name: 'Create Virtual IED',
117+
src: '/src/menu/VirtualTemplateIED.js',
118+
icon: 'developer_board',
119+
default: false,
120+
kind: 'menu',
121+
requireDoc: true,
122+
position: 'middle'
123+
},
115124
{
116125
name: 'Subscriber Update',
117126
src: '/src/menu/SubscriberInfo.js',

src/foundation.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -598,19 +598,21 @@ function lNodeSelector(tagName: SCLTag, identity: string): string {
598598
if (identity.endsWith(')')) {
599599
const [parentIdentity, childIdentity] = pathParts(identity);
600600
const [lnClass, lnType] = childIdentity
601-
.substring(1, identity.length - 2)
601+
.substring(1, childIdentity.length - 1)
602602
.split(' ');
603603

604604
if (!lnClass || !lnType) return voidSelector;
605605

606-
return tags[tagName].parents
607-
.map(
608-
parentTag =>
609-
`${selector(
610-
parentTag,
611-
parentIdentity
612-
)}>${tagName}[iedName="None"][lnClass="${lnClass}"][lnType="${lnType}"]`
613-
)
606+
const parentSelectors = tags[tagName].parents.flatMap(parentTag =>
607+
selector(parentTag, parentIdentity).split(',')
608+
);
609+
610+
return crossProduct(
611+
parentSelectors,
612+
['>'],
613+
[`${tagName}[iedName="None"][lnClass="${lnClass}"][lnType="${lnType}"]`]
614+
)
615+
.map(strings => strings.join(''))
614616
.join(',');
615617
}
616618

src/menu/VirtualTemplateIED.ts

Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
import {
2+
css,
3+
html,
4+
LitElement,
5+
property,
6+
query,
7+
queryAll,
8+
state,
9+
TemplateResult,
10+
} from 'lit-element';
11+
import { translate } from 'lit-translate';
12+
13+
import '@material/mwc-dialog';
14+
import '@material/mwc-list';
15+
import '@material/mwc-list/mwc-list-item';
16+
import '@material/mwc-list/mwc-check-list-item';
17+
import '@material/mwc-list/mwc-radio-list-item';
18+
import { Dialog } from '@material/mwc-dialog';
19+
import { CheckListItem } from '@material/mwc-list/mwc-check-list-item';
20+
import { Select } from '@material/mwc-select';
21+
22+
import '../filtered-list.js';
23+
import {
24+
getChildElementsByTagName,
25+
identity,
26+
newActionEvent,
27+
selector,
28+
} from '../foundation.js';
29+
import { WizardTextField } from '../wizard-textfield.js';
30+
import {
31+
getFunctionNamingPrefix,
32+
getNonLeafParent,
33+
getSpecificationIED,
34+
getUniqueFunctionName,
35+
LDeviceDescription,
36+
} from './virtualtemplateied/foundation.js';
37+
38+
export type FunctionElementDescription = {
39+
uniqueName: string;
40+
lNodes: Element[];
41+
lln0?: Element;
42+
};
43+
44+
/** converts FunctionElementDescription's to LDeviceDescription's */
45+
function getLDeviceDescriptions(
46+
functions: Record<string, FunctionElementDescription>,
47+
selectedLNodes: Element[],
48+
selectedLLN0s: string[]
49+
): LDeviceDescription[] {
50+
const lDeviceDescriptions: LDeviceDescription[] = [];
51+
52+
Object.values(functions).forEach(functionDescription => {
53+
if (
54+
functionDescription.lNodes.some(lNode => selectedLNodes.includes(lNode))
55+
) {
56+
const lLN0 = selectedLLN0s.find(selectedLLN0 =>
57+
selectedLLN0.includes(functionDescription.uniqueName)
58+
)!;
59+
const lnType = lLN0?.split(': ')[1];
60+
61+
lDeviceDescriptions.push({
62+
validLdInst: functionDescription.uniqueName,
63+
anyLNs: [
64+
{ prefix: null, lnClass: 'LLN0', inst: '', lnType },
65+
...functionDescription.lNodes
66+
.filter(lNode => selectedLNodes.includes(lNode))
67+
.map(lNode => {
68+
return {
69+
prefix: getFunctionNamingPrefix(lNode),
70+
lnClass: lNode.getAttribute('lnClass')!,
71+
inst: lNode.getAttribute('lnInst')!,
72+
lnType: lNode.getAttribute('lnType')!,
73+
};
74+
}),
75+
],
76+
});
77+
}
78+
});
79+
80+
return lDeviceDescriptions;
81+
}
82+
83+
/** Groups all incomming LNode's with non-leaf parent function type elements */
84+
function groupLNodesToFunctions(
85+
lNodes: Element[]
86+
): Record<string, FunctionElementDescription> {
87+
const functionElements: Record<string, FunctionElementDescription> = {};
88+
89+
lNodes.forEach(lNode => {
90+
const parentFunction = getNonLeafParent(lNode);
91+
if (!parentFunction) return;
92+
93+
if (functionElements[identity(parentFunction)])
94+
functionElements[identity(parentFunction)].lNodes.push(lNode);
95+
else {
96+
functionElements[identity(parentFunction)] = {
97+
uniqueName: getUniqueFunctionName(parentFunction),
98+
lNodes: [lNode],
99+
lln0: getChildElementsByTagName(parentFunction, 'LNode').find(
100+
lNode => lNode.getAttribute('lnClass') === 'LLN0'
101+
),
102+
};
103+
}
104+
});
105+
106+
return functionElements;
107+
}
108+
109+
export default class VirtualTemplateIED extends LitElement {
110+
@property({ attribute: false })
111+
doc!: XMLDocument;
112+
@state()
113+
get isValidManufacturer(): boolean {
114+
const manufacturer = this.dialog?.querySelector<WizardTextField>(
115+
'wizard-textfield[label="manufacturer"]'
116+
)!.value;
117+
118+
return (manufacturer && manufacturer !== '') || false;
119+
}
120+
@state()
121+
get isValidApName(): boolean {
122+
const apName = this.dialog?.querySelector<WizardTextField>(
123+
'wizard-textfield[label="AccessPoint name"]'
124+
)!.value;
125+
126+
return (apName && apName !== '') || false;
127+
}
128+
@state()
129+
get someItemsSelected(): boolean {
130+
if (!this.selectedLNodeItems) return false;
131+
return !!this.selectedLNodeItems.length;
132+
}
133+
@state()
134+
get validPriparyAction(): boolean {
135+
return (
136+
this.someItemsSelected && this.isValidManufacturer && this.isValidApName
137+
);
138+
}
139+
140+
get unreferencedLNodes(): Element[] {
141+
return Array.from(
142+
this.doc.querySelectorAll('LNode[iedName="None"]')
143+
).filter(lNode => lNode.getAttribute('lnClass') !== 'LLN0');
144+
}
145+
146+
get lLN0s(): Element[] {
147+
return Array.from(this.doc.querySelectorAll('LNodeType[lnClass="LLN0"]'));
148+
}
149+
150+
@query('mwc-dialog') dialog!: Dialog;
151+
@queryAll('mwc-check-list-item[selected]')
152+
selectedLNodeItems?: CheckListItem[];
153+
154+
async run(): Promise<void> {
155+
this.dialog.open = true;
156+
}
157+
158+
private onPrimaryAction(
159+
functions: Record<string, FunctionElementDescription>
160+
): void {
161+
const selectedLNode = Array.from(
162+
this.dialog.querySelectorAll<CheckListItem>(
163+
'mwc-check-list-item[selected]:not([disabled])'
164+
) ?? []
165+
).map(
166+
selectedItem =>
167+
this.doc.querySelector(selector('LNode', selectedItem.value))!
168+
);
169+
if (!selectedLNode.length) return;
170+
171+
const selectedLLN0s = Array.from(
172+
this.dialog.querySelectorAll<Select>('mwc-select') ?? []
173+
).map(selectedItem => selectedItem.value);
174+
175+
const manufacturer = this.dialog.querySelector<WizardTextField>(
176+
'wizard-textfield[label="manufacturer"]'
177+
)!.value;
178+
const desc = this.dialog.querySelector<WizardTextField>(
179+
'wizard-textfield[label="desc"]'
180+
)!.maybeValue;
181+
const apName = this.dialog.querySelector<WizardTextField>(
182+
'wizard-textfield[label="AccessPoint name"]'
183+
)!.value;
184+
185+
const ied = getSpecificationIED(this.doc, {
186+
manufacturer,
187+
desc,
188+
apName,
189+
lDevices: getLDeviceDescriptions(functions, selectedLNode, selectedLLN0s),
190+
});
191+
192+
// checkValidity: () => true disables name check as is the same here: SPECIFICATION
193+
this.dispatchEvent(
194+
newActionEvent({
195+
new: { parent: this.doc.documentElement, element: ied },
196+
checkValidity: () => true,
197+
})
198+
);
199+
this.dialog.close();
200+
}
201+
202+
private onClosed(ae: CustomEvent<{ action: string } | null>): void {
203+
if (!(ae.target instanceof Dialog && ae.detail?.action)) return;
204+
}
205+
206+
private renderLLN0s(
207+
functionID: string,
208+
lLN0Types: Element[],
209+
lNode?: Element
210+
): TemplateResult {
211+
if (!lNode && !lLN0Types.length) return html``;
212+
213+
if (lNode)
214+
return html`<mwc-select
215+
disabled
216+
naturalMenuWidth
217+
value="${functionID + ': ' + lNode.getAttribute('lnType')}"
218+
style="width:100%"
219+
label="LLN0"
220+
>${html`<mwc-list-item
221+
value="${functionID + ': ' + lNode.getAttribute('lnType')}"
222+
>${lNode.getAttribute('lnType')}
223+
</mwc-list-item>`}</mwc-select
224+
>`;
225+
226+
return html`<mwc-select
227+
naturalMenuWidth
228+
style="width:100%"
229+
label="LLN0"
230+
value="${functionID + ': ' + lLN0Types[0].getAttribute('id')}"
231+
>${lLN0Types.map(lLN0Type => {
232+
return html`<mwc-list-item
233+
value="${functionID + ': ' + lLN0Type.getAttribute('id')}"
234+
>${lLN0Type.getAttribute('id')}</mwc-list-item
235+
>`;
236+
})}</mwc-select
237+
>`;
238+
}
239+
240+
private renderLNodes(lNodes: Element[], disabled: boolean): TemplateResult[] {
241+
return lNodes.map(lNode => {
242+
const prefix = getFunctionNamingPrefix(lNode);
243+
const lnClass = lNode.getAttribute('lnClass')!;
244+
const lnInst = lNode.getAttribute('lnInst')!;
245+
246+
const label = prefix + ' ' + lnClass + ' ' + lnInst;
247+
return html`<mwc-check-list-item
248+
?disabled=${disabled}
249+
value="${identity(lNode)}"
250+
>${label}</mwc-check-list-item
251+
>`;
252+
});
253+
}
254+
255+
render(): TemplateResult {
256+
if (!this.doc) return html``;
257+
258+
const existValidLLN0 = this.lLN0s.length !== 0;
259+
260+
const functionElementDescriptions = groupLNodesToFunctions(
261+
this.unreferencedLNodes
262+
);
263+
264+
return html`<mwc-dialog
265+
heading="Create SPECIFICATION type IED"
266+
@closed=${this.onClosed}
267+
><div>
268+
<wizard-textfield
269+
label="manufacturer"
270+
.maybeValue=${''}
271+
required
272+
@keypress=${() => this.requestUpdate()}
273+
></wizard-textfield>
274+
<wizard-textfield
275+
label="desc"
276+
.maybeValue=${null}
277+
nullable
278+
></wizard-textfield>
279+
<wizard-textfield
280+
label="AccessPoint name"
281+
.maybeValue=${''}
282+
required
283+
@keypress=${() => this.requestUpdate()}
284+
></wizard-textfield>
285+
<filtered-list multi @selected=${() => this.requestUpdate()}
286+
>${Object.entries(functionElementDescriptions).flatMap(
287+
([id, functionDescription]) => [
288+
html`<mwc-list-item
289+
twoline
290+
noninteractive
291+
value="${id}"
292+
style="font-weight:500"
293+
><span>${functionDescription.uniqueName}</span
294+
><span slot="secondary"
295+
>${existValidLLN0 ? id : 'Invalid LD: Missing LLN0'}</span
296+
></mwc-list-item
297+
>`,
298+
this.renderLLN0s(
299+
functionDescription.uniqueName,
300+
this.lLN0s,
301+
functionDescription.lln0
302+
),
303+
...this.renderLNodes(functionDescription.lNodes, !existValidLLN0),
304+
html`<li padded divider role="separator"></li>`,
305+
]
306+
)}</filtered-list
307+
>
308+
</div>
309+
<mwc-button
310+
slot="secondaryAction"
311+
dialogAction="close"
312+
label="${translate('close')}"
313+
style="--mdc-theme-primary: var(--mdc-theme-error)"
314+
></mwc-button>
315+
<mwc-button
316+
?disabled=${!this.validPriparyAction}
317+
slot="primaryAction"
318+
icon="save"
319+
label="${translate('save')}"
320+
trailingIcon
321+
@click=${() => this.onPrimaryAction(functionElementDescriptions)}
322+
></mwc-button
323+
></mwc-dialog>`;
324+
}
325+
326+
static styles = css`
327+
mwc-dialog {
328+
--mdc-dialog-max-width: 92vw;
329+
}
330+
331+
div {
332+
display: flex;
333+
flex-direction: column;
334+
}
335+
336+
div > * {
337+
display: block;
338+
margin-top: 16px;
339+
}
340+
`;
341+
}

0 commit comments

Comments
 (0)