@matter/protocol 0.12.2-alpha.0-20250130-d012e3082 → 0.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/interaction/AccessControlManager.d.ts +8 -5
- package/dist/cjs/interaction/AccessControlManager.d.ts.map +1 -1
- package/dist/cjs/interaction/AccessControlManager.js +1 -1
- package/dist/cjs/interaction/AccessControlManager.js.map +1 -1
- package/dist/cjs/interaction/AttributeDataEncoder.d.ts +8 -1
- package/dist/cjs/interaction/AttributeDataEncoder.d.ts.map +1 -1
- package/dist/cjs/interaction/AttributeDataEncoder.js.map +1 -1
- package/dist/cjs/interaction/InteractionMessenger.d.ts +7 -4
- package/dist/cjs/interaction/InteractionMessenger.d.ts.map +1 -1
- package/dist/cjs/interaction/InteractionMessenger.js +33 -26
- package/dist/cjs/interaction/InteractionMessenger.js.map +1 -1
- package/dist/cjs/interaction/InteractionServer.d.ts +22 -6
- package/dist/cjs/interaction/InteractionServer.d.ts.map +1 -1
- package/dist/cjs/interaction/InteractionServer.js +178 -165
- package/dist/cjs/interaction/InteractionServer.js.map +1 -1
- package/dist/cjs/interaction/ServerSubscription.d.ts +13 -3
- package/dist/cjs/interaction/ServerSubscription.d.ts.map +1 -1
- package/dist/cjs/interaction/ServerSubscription.js +121 -91
- package/dist/cjs/interaction/ServerSubscription.js.map +2 -2
- package/dist/cjs/peer/ControllerCommissioningFlow.js +7 -6
- package/dist/cjs/peer/ControllerCommissioningFlow.js.map +1 -1
- package/dist/esm/interaction/AccessControlManager.d.ts +8 -5
- package/dist/esm/interaction/AccessControlManager.d.ts.map +1 -1
- package/dist/esm/interaction/AccessControlManager.js +8 -2
- package/dist/esm/interaction/AccessControlManager.js.map +1 -1
- package/dist/esm/interaction/AttributeDataEncoder.d.ts +8 -1
- package/dist/esm/interaction/AttributeDataEncoder.d.ts.map +1 -1
- package/dist/esm/interaction/AttributeDataEncoder.js.map +1 -1
- package/dist/esm/interaction/InteractionMessenger.d.ts +7 -4
- package/dist/esm/interaction/InteractionMessenger.d.ts.map +1 -1
- package/dist/esm/interaction/InteractionMessenger.js +41 -27
- package/dist/esm/interaction/InteractionMessenger.js.map +1 -1
- package/dist/esm/interaction/InteractionServer.d.ts +22 -6
- package/dist/esm/interaction/InteractionServer.d.ts.map +1 -1
- package/dist/esm/interaction/InteractionServer.js +178 -165
- package/dist/esm/interaction/InteractionServer.js.map +1 -1
- package/dist/esm/interaction/ServerSubscription.d.ts +13 -3
- package/dist/esm/interaction/ServerSubscription.d.ts.map +1 -1
- package/dist/esm/interaction/ServerSubscription.js +121 -91
- package/dist/esm/interaction/ServerSubscription.js.map +2 -2
- package/dist/esm/peer/ControllerCommissioningFlow.js +7 -6
- package/dist/esm/peer/ControllerCommissioningFlow.js.map +1 -1
- package/package.json +6 -6
- package/src/interaction/AccessControlManager.ts +20 -8
- package/src/interaction/AttributeDataEncoder.ts +11 -1
- package/src/interaction/InteractionMessenger.ts +65 -33
- package/src/interaction/InteractionServer.ts +224 -188
- package/src/interaction/ServerSubscription.ts +155 -108
- package/src/peer/ControllerCommissioningFlow.ts +7 -7
|
@@ -22,6 +22,7 @@ import { PeerAddress } from "#peer/PeerAddress.js";
|
|
|
22
22
|
import type { MessageExchange } from "#protocol/MessageExchange.js";
|
|
23
23
|
import { SecureSession } from "#session/SecureSession.js";
|
|
24
24
|
import {
|
|
25
|
+
EndpointNumber,
|
|
25
26
|
EventNumber,
|
|
26
27
|
INTERACTION_PROTOCOL_ID,
|
|
27
28
|
StatusCode,
|
|
@@ -37,7 +38,7 @@ import {
|
|
|
37
38
|
import { AnyAttributeServer, FabricScopedAttributeServer } from "../cluster/server/AttributeServer.js";
|
|
38
39
|
import { AnyEventServer, FabricSensitiveEventServer } from "../cluster/server/EventServer.js";
|
|
39
40
|
import { NoChannelError } from "../protocol/ChannelManager.js";
|
|
40
|
-
import {
|
|
41
|
+
import { EventReportPayload } from "./AttributeDataEncoder.js";
|
|
41
42
|
import { InteractionEndpointStructure } from "./InteractionEndpointStructure.js";
|
|
42
43
|
import { InteractionServerMessenger } from "./InteractionMessenger.js";
|
|
43
44
|
import {
|
|
@@ -133,7 +134,11 @@ export interface ServerSubscriptionContext {
|
|
|
133
134
|
path: AttributePath,
|
|
134
135
|
attribute: AnyAttributeServer<unknown>,
|
|
135
136
|
offline?: boolean,
|
|
136
|
-
):
|
|
137
|
+
): { version: number; value: unknown };
|
|
138
|
+
readEndpointAttributesForSubscription(
|
|
139
|
+
endpointId: EndpointNumber,
|
|
140
|
+
attributes: { path: AttributePath; attribute: AnyAttributeServer<unknown>; offline?: boolean }[],
|
|
141
|
+
): { path: AttributePath; attribute: AnyAttributeServer<unknown>; version: number; value: unknown }[];
|
|
137
142
|
readEvent(
|
|
138
143
|
path: EventPath,
|
|
139
144
|
event: AnyEventServer<any, any>,
|
|
@@ -425,7 +430,7 @@ export class ServerSubscription extends Subscription {
|
|
|
425
430
|
const { newAttributes } = this.registerNewAttributes();
|
|
426
431
|
|
|
427
432
|
for (const { path, attribute } of newAttributes) {
|
|
428
|
-
const { version, value } =
|
|
433
|
+
const { version, value } = this.#context.readAttribute(path, attribute);
|
|
429
434
|
|
|
430
435
|
// We do not do any version filtering for attributes that are newly added to make sure controller gets
|
|
431
436
|
// most current state
|
|
@@ -639,69 +644,7 @@ export class ServerSubscription extends Subscription {
|
|
|
639
644
|
}
|
|
640
645
|
}
|
|
641
646
|
|
|
642
|
-
async
|
|
643
|
-
this.#updateTimer.stop();
|
|
644
|
-
|
|
645
|
-
const { newAttributes, attributeErrors } = this.registerNewAttributes();
|
|
646
|
-
|
|
647
|
-
const dataVersionFilterMap = new Map<string, number>(
|
|
648
|
-
this.criteria.dataVersionFilters?.map(({ path, dataVersion }) => [clusterPathToId(path), dataVersion]) ??
|
|
649
|
-
[],
|
|
650
|
-
);
|
|
651
|
-
|
|
652
|
-
let attributesFilteredWithVersion = false;
|
|
653
|
-
const attributes = new Array<{
|
|
654
|
-
path: TypeFromSchema<typeof TlvAttributePath>;
|
|
655
|
-
value: any;
|
|
656
|
-
version: number;
|
|
657
|
-
schema: TlvSchema<any>;
|
|
658
|
-
attribute: AnyAttributeServer<any>;
|
|
659
|
-
}>();
|
|
660
|
-
for (const { path, attribute } of newAttributes) {
|
|
661
|
-
try {
|
|
662
|
-
const { value, version } = await this.#context.readAttribute(path, attribute);
|
|
663
|
-
if (value === undefined) continue;
|
|
664
|
-
|
|
665
|
-
const { nodeId, endpointId, clusterId } = path;
|
|
666
|
-
|
|
667
|
-
const versionFilterValue =
|
|
668
|
-
endpointId !== undefined && clusterId !== undefined
|
|
669
|
-
? dataVersionFilterMap.get(clusterPathToId({ nodeId, endpointId, clusterId }))
|
|
670
|
-
: undefined;
|
|
671
|
-
if (versionFilterValue !== undefined && versionFilterValue === version) {
|
|
672
|
-
attributesFilteredWithVersion = true;
|
|
673
|
-
continue;
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
attributes.push({ path, value, version, schema: attribute.schema, attribute });
|
|
677
|
-
} catch (error) {
|
|
678
|
-
if (StatusResponseError.is(error, StatusCode.UnsupportedAccess)) {
|
|
679
|
-
logger.warn(`Permission denied reading attribute ${this.#structure.resolveAttributeName(path)}`);
|
|
680
|
-
} else {
|
|
681
|
-
logger.warn(`Error reading attribute ${this.#structure.resolveAttributeName(path)}:`, error);
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
const attributeReportsPayload: AttributeReportPayload[] = attributes.map(
|
|
686
|
-
({ path, schema, value, version, attribute }) => ({
|
|
687
|
-
hasFabricSensitiveData: attribute.hasFabricSensitiveData,
|
|
688
|
-
attributeData: {
|
|
689
|
-
path,
|
|
690
|
-
dataVersion: version,
|
|
691
|
-
payload: value,
|
|
692
|
-
schema,
|
|
693
|
-
},
|
|
694
|
-
}),
|
|
695
|
-
);
|
|
696
|
-
attributeErrors.forEach(attributeStatus =>
|
|
697
|
-
attributeReportsPayload.push({
|
|
698
|
-
hasFabricSensitiveData: false,
|
|
699
|
-
attributeStatus,
|
|
700
|
-
}),
|
|
701
|
-
);
|
|
702
|
-
|
|
703
|
-
const { newEvents, eventErrors } = this.#registerNewEvents();
|
|
704
|
-
|
|
647
|
+
async #collectInitialEventReportPayloads(newEvents: EventWithPath[]) {
|
|
705
648
|
let eventsFiltered = false;
|
|
706
649
|
const eventReportsPayload = new Array<EventReportPayload>();
|
|
707
650
|
for (const { path, event } of newEvents) {
|
|
@@ -745,8 +688,91 @@ export class ServerSubscription extends Subscription {
|
|
|
745
688
|
}
|
|
746
689
|
});
|
|
747
690
|
|
|
691
|
+
return { eventReportsPayload, eventsFiltered };
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Returns an iterator that yields the initial subscription data to be sent to the controller.
|
|
696
|
+
* The iterator will yield all attributes and events that match the subscription criteria.
|
|
697
|
+
* A thrown exception will cancel the sending process immediately.
|
|
698
|
+
* TODO: Streamline all this with the normal Read flow to also handle Concrete Path subscriptions with errors correctly
|
|
699
|
+
*/
|
|
700
|
+
*#iterateInitialSubscriptionData(
|
|
701
|
+
attributesToSend: {
|
|
702
|
+
newAttributes: AttributeWithPath[];
|
|
703
|
+
attributeErrors: TypeFromSchema<typeof TlvAttributeStatus>[];
|
|
704
|
+
},
|
|
705
|
+
eventsToSend: {
|
|
706
|
+
eventReportsPayload: EventReportPayload[];
|
|
707
|
+
eventsFiltered: boolean;
|
|
708
|
+
eventErrors: TypeFromSchema<typeof TlvEventStatus>[];
|
|
709
|
+
},
|
|
710
|
+
) {
|
|
711
|
+
const dataVersionFilterMap = new Map<string, number>(
|
|
712
|
+
this.criteria.dataVersionFilters?.map(({ path, dataVersion }) => [clusterPathToId(path), dataVersion]) ??
|
|
713
|
+
[],
|
|
714
|
+
);
|
|
715
|
+
|
|
716
|
+
const { newAttributes, attributeErrors } = attributesToSend;
|
|
717
|
+
const { eventReportsPayload, eventsFiltered, eventErrors } = eventsToSend;
|
|
718
|
+
|
|
719
|
+
logger.debug(
|
|
720
|
+
`Initializes Subscription with ${newAttributes.length} attributes and ${eventReportsPayload.length} events.`,
|
|
721
|
+
);
|
|
722
|
+
|
|
723
|
+
let attributesFilteredWithVersion = false;
|
|
724
|
+
|
|
725
|
+
const attributesPerCluster = new Map<EndpointNumber, AttributeWithPath[]>();
|
|
726
|
+
for (const { path, attribute } of newAttributes) {
|
|
727
|
+
const { endpointId } = path;
|
|
728
|
+
const endpointAttributes = attributesPerCluster.get(endpointId) ?? new Array<AttributeWithPath>();
|
|
729
|
+
endpointAttributes.push({ path, attribute });
|
|
730
|
+
attributesPerCluster.set(endpointId, endpointAttributes);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
let attributesCounter = 0;
|
|
734
|
+
for (const endpointId of attributesPerCluster.keys()) {
|
|
735
|
+
const endpointAttributes = attributesPerCluster.get(endpointId)!;
|
|
736
|
+
attributesPerCluster.delete(endpointId);
|
|
737
|
+
for (const { path, attribute, value, version } of this.#context.readEndpointAttributesForSubscription(
|
|
738
|
+
endpointId,
|
|
739
|
+
endpointAttributes,
|
|
740
|
+
)) {
|
|
741
|
+
if (value === undefined) continue;
|
|
742
|
+
|
|
743
|
+
const { nodeId, endpointId, clusterId } = path;
|
|
744
|
+
|
|
745
|
+
const versionFilterValue =
|
|
746
|
+
endpointId !== undefined && clusterId !== undefined
|
|
747
|
+
? dataVersionFilterMap.get(clusterPathToId({ nodeId, endpointId, clusterId }))
|
|
748
|
+
: undefined;
|
|
749
|
+
if (versionFilterValue !== undefined && versionFilterValue === version) {
|
|
750
|
+
attributesFilteredWithVersion = true;
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
attributesCounter++;
|
|
755
|
+
yield {
|
|
756
|
+
hasFabricSensitiveData: attribute.hasFabricSensitiveData,
|
|
757
|
+
attributeData: {
|
|
758
|
+
path,
|
|
759
|
+
dataVersion: version,
|
|
760
|
+
payload: value,
|
|
761
|
+
schema: attribute.schema,
|
|
762
|
+
},
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
for (const attributeStatus of attributeErrors) {
|
|
768
|
+
yield {
|
|
769
|
+
hasFabricSensitiveData: false,
|
|
770
|
+
attributeStatus,
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
|
|
748
774
|
if (
|
|
749
|
-
|
|
775
|
+
attributesCounter === 0 &&
|
|
750
776
|
!attributesFilteredWithVersion &&
|
|
751
777
|
eventReportsPayload.length === 0 &&
|
|
752
778
|
!eventsFiltered
|
|
@@ -757,27 +783,38 @@ export class ServerSubscription extends Subscription {
|
|
|
757
783
|
);
|
|
758
784
|
}
|
|
759
785
|
|
|
760
|
-
|
|
761
|
-
|
|
786
|
+
for (const eventReport of eventReportsPayload) {
|
|
787
|
+
yield eventReport;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
for (const eventStatus of eventErrors) {
|
|
791
|
+
yield {
|
|
762
792
|
hasFabricSensitiveData: false,
|
|
763
793
|
eventStatus,
|
|
764
|
-
}
|
|
765
|
-
|
|
794
|
+
};
|
|
795
|
+
}
|
|
766
796
|
|
|
767
|
-
logger.debug(
|
|
768
|
-
`Initialize Subscription with ${attributes.length} attributes and ${eventReportsPayload.length} events.`,
|
|
769
|
-
);
|
|
770
797
|
this.#lastUpdateTimeMs = Time.nowMs();
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
async sendInitialReport(messenger: InteractionServerMessenger) {
|
|
801
|
+
this.#updateTimer.stop();
|
|
802
|
+
|
|
803
|
+
const { newAttributes, attributeErrors } = this.registerNewAttributes();
|
|
804
|
+
const { newEvents, eventErrors } = this.#registerNewEvents();
|
|
805
|
+
const { eventReportsPayload, eventsFiltered } = await this.#collectInitialEventReportPayloads(newEvents);
|
|
771
806
|
|
|
772
807
|
await messenger.sendDataReport(
|
|
773
808
|
{
|
|
774
809
|
suppressResponse: false, // we always need proper response for initial report
|
|
775
810
|
subscriptionId: this.id,
|
|
776
811
|
interactionModelRevision: Specification.INTERACTION_MODEL_REVISION,
|
|
777
|
-
attributeReportsPayload,
|
|
778
|
-
eventReportsPayload,
|
|
779
812
|
},
|
|
780
813
|
this.criteria.isFabricFiltered,
|
|
814
|
+
this.#iterateInitialSubscriptionData(
|
|
815
|
+
{ newAttributes, attributeErrors },
|
|
816
|
+
{ eventReportsPayload, eventsFiltered, eventErrors },
|
|
817
|
+
),
|
|
781
818
|
);
|
|
782
819
|
}
|
|
783
820
|
|
|
@@ -805,16 +842,15 @@ export class ServerSubscription extends Subscription {
|
|
|
805
842
|
// We cannot be sure what value we got for fabric filtered attributes (and from which fabric),
|
|
806
843
|
// so get it again for this relevant fabric. This also makes sure that fabric sensitive fields are filtered
|
|
807
844
|
// TODO: Remove this once we remove the legacy API and go away from using AttributeServers in the background
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
});
|
|
816
|
-
this.#prepareDataUpdate();
|
|
845
|
+
const { value } = this.#context.readAttribute(path, attribute, true);
|
|
846
|
+
this.#outstandingAttributeUpdates.set(attributePathToId(path), {
|
|
847
|
+
attribute,
|
|
848
|
+
path,
|
|
849
|
+
schema,
|
|
850
|
+
version,
|
|
851
|
+
value,
|
|
817
852
|
});
|
|
853
|
+
this.#prepareDataUpdate();
|
|
818
854
|
}
|
|
819
855
|
this.#outstandingAttributeUpdates.set(attributePathToId(path), { attribute, path, schema, version, value });
|
|
820
856
|
this.#prepareDataUpdate();
|
|
@@ -878,6 +914,38 @@ export class ServerSubscription extends Subscription {
|
|
|
878
914
|
await super.close();
|
|
879
915
|
}
|
|
880
916
|
|
|
917
|
+
/**
|
|
918
|
+
* Iterates over all attributes and events that have changed since the last update and sends them to
|
|
919
|
+
* the controller.
|
|
920
|
+
* A thrown exception will cancel the sending process immediately.
|
|
921
|
+
*/
|
|
922
|
+
*#iterateDataUpdate(attributes: AttributePathWithValueVersion<any>[], events: EventPathWithEventData<any>[]) {
|
|
923
|
+
for (const {
|
|
924
|
+
path,
|
|
925
|
+
schema,
|
|
926
|
+
value: payload,
|
|
927
|
+
version: dataVersion,
|
|
928
|
+
attribute: { hasFabricSensitiveData },
|
|
929
|
+
} of attributes) {
|
|
930
|
+
yield {
|
|
931
|
+
hasFabricSensitiveData,
|
|
932
|
+
attributeData: { path, dataVersion, schema, payload },
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
for (const {
|
|
937
|
+
path,
|
|
938
|
+
schema,
|
|
939
|
+
event,
|
|
940
|
+
data: { number: eventNumber, priority, epochTimestamp, payload },
|
|
941
|
+
} of events) {
|
|
942
|
+
yield {
|
|
943
|
+
hasFabricSensitiveData: event.hasFabricSensitiveData,
|
|
944
|
+
eventData: { path, eventNumber, priority, epochTimestamp, schema, payload },
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
881
949
|
private async sendUpdateMessage(
|
|
882
950
|
attributes: AttributePathWithValueVersion<any>[],
|
|
883
951
|
events: EventPathWithEventData<any>[],
|
|
@@ -905,6 +973,7 @@ export class ServerSubscription extends Subscription {
|
|
|
905
973
|
interactionModelRevision: Specification.INTERACTION_MODEL_REVISION,
|
|
906
974
|
},
|
|
907
975
|
this.criteria.isFabricFiltered,
|
|
976
|
+
undefined,
|
|
908
977
|
!this.isClosed, // Do not wait for ack when closed
|
|
909
978
|
);
|
|
910
979
|
} else {
|
|
@@ -913,31 +982,9 @@ export class ServerSubscription extends Subscription {
|
|
|
913
982
|
suppressResponse: false, // Non-empty data reports always need to send response
|
|
914
983
|
subscriptionId: this.id,
|
|
915
984
|
interactionModelRevision: Specification.INTERACTION_MODEL_REVISION,
|
|
916
|
-
attributeReportsPayload: attributes.map(({ path, schema, value, version, attribute }) => ({
|
|
917
|
-
hasFabricSensitiveData: attribute.hasFabricSensitiveData,
|
|
918
|
-
attributeData: {
|
|
919
|
-
path,
|
|
920
|
-
dataVersion: version,
|
|
921
|
-
schema,
|
|
922
|
-
payload: value,
|
|
923
|
-
},
|
|
924
|
-
})),
|
|
925
|
-
eventReportsPayload: events.map(({ path, schema, event, data }) => {
|
|
926
|
-
const { number, priority, epochTimestamp, payload } = data;
|
|
927
|
-
return {
|
|
928
|
-
hasFabricSensitiveData: event.hasFabricSensitiveData,
|
|
929
|
-
eventData: {
|
|
930
|
-
path,
|
|
931
|
-
eventNumber: number,
|
|
932
|
-
priority,
|
|
933
|
-
epochTimestamp,
|
|
934
|
-
schema,
|
|
935
|
-
payload,
|
|
936
|
-
},
|
|
937
|
-
};
|
|
938
|
-
}),
|
|
939
985
|
},
|
|
940
986
|
this.criteria.isFabricFiltered,
|
|
987
|
+
this.#iterateDataUpdate(attributes, events),
|
|
941
988
|
!this.isClosed, // Do not wait for ack when closed
|
|
942
989
|
);
|
|
943
990
|
}
|
|
@@ -350,13 +350,6 @@ export class ControllerCommissioningFlow {
|
|
|
350
350
|
stepLogic: () => this.#certificates(),
|
|
351
351
|
});
|
|
352
352
|
|
|
353
|
-
this.#commissioningSteps.push({
|
|
354
|
-
stepNumber: 9,
|
|
355
|
-
subStepNumber: 2,
|
|
356
|
-
name: "OperationalCredentials.UpdateFabricLabel",
|
|
357
|
-
stepLogic: () => this.#updateFabricLabel(),
|
|
358
|
-
});
|
|
359
|
-
|
|
360
353
|
// TODO Step 10: TimeSynchronization.SetTrustedTimeSource if supported
|
|
361
354
|
|
|
362
355
|
this.#commissioningSteps.push({
|
|
@@ -412,6 +405,13 @@ export class ControllerCommissioningFlow {
|
|
|
412
405
|
name: "GeneralCommissioning.Complete",
|
|
413
406
|
stepLogic: () => this.#completeCommissioning(),
|
|
414
407
|
});
|
|
408
|
+
|
|
409
|
+
this.#commissioningSteps.push({
|
|
410
|
+
stepNumber: 17, // Should be allowed in Step 9, but Tasmota is not supporting this
|
|
411
|
+
subStepNumber: 1,
|
|
412
|
+
name: "OperationalCredentials.UpdateFabricLabel",
|
|
413
|
+
stepLogic: () => this.#updateFabricLabel(),
|
|
414
|
+
});
|
|
415
415
|
}
|
|
416
416
|
|
|
417
417
|
#sortSteps() {
|