@project-chip/matter.js
Version:
Matter protocol in pure js
868 lines (791 loc) • 32.2 kB
text/typescript
/**
* @license
* Copyright 2022-2025 Matter.js Authors
* SPDX-License-Identifier: Apache-2.0
*/
import { Endpoint } from "#device/Endpoint.js";
import { Diagnostic, ImplementationError, InternalError, Logger, MatterError, camelize, isDeepEqual } from "#general";
import { AccessLevel, AttributeModel, ClusterModel, DatatypeModel, FabricIndex, MatterModel } from "#model";
import { Fabric, Message, NoAssociatedFabricError, SecureSession, Session } from "#protocol";
import {
Attribute,
AttributeId,
Attributes,
BitSchema,
Cluster,
Commands,
Events,
StatusCode,
StatusResponseError,
TlvSchema,
TypeFromPartialBitSchema,
ValidationError,
} from "#types";
import { ClusterDatasource } from "./ClusterDatasource.js";
const logger = Logger.get("AttributeServer");
const FabricIndexName = camelize(FabricIndex.name);
/**
* Thrown when an operation cannot complete because fabric information is
* unavailable.
*/
export class FabricScopeError extends MatterError {}
export type AnyAttributeServer<T = any> = AttributeServer<T> | FabricScopedAttributeServer<T> | FixedAttributeServer<T>;
type DelayedChangeData = {
oldValue: any;
newValue: any;
changed: boolean;
};
/**
* Factory function to create an attribute server.
*/
export function createAttributeServer<
T,
F extends BitSchema,
SF extends TypeFromPartialBitSchema<F>,
A extends Attributes,
C extends Commands,
E extends Events,
>(
clusterDef: Cluster<F, SF, A, C, E>,
attributeDef: Attribute<T, F>,
attributeName: string,
initValue: T,
datasource: ClusterDatasource,
getter?: (session?: Session, endpoint?: Endpoint, isFabricFiltered?: boolean, message?: Message) => T,
setter?: (value: T, session?: Session, endpoint?: Endpoint, message?: Message) => boolean,
validator?: (value: T, session?: Session, endpoint?: Endpoint) => void,
) {
const {
id,
schema,
writable,
fabricScoped,
fixed,
omitChanges,
timed,
default: defaultValue,
readAcl,
writeAcl,
} = attributeDef;
if (fixed) {
return new FixedAttributeServer(
id,
attributeName,
readAcl,
writeAcl,
schema,
writable,
false,
timed,
initValue,
defaultValue,
datasource,
getter,
);
}
if (fabricScoped) {
return new FabricScopedAttributeServer(
id,
attributeName,
readAcl,
writeAcl,
schema,
writable,
!omitChanges,
timed,
initValue,
defaultValue,
clusterDef,
datasource,
getter,
setter,
validator,
);
}
return new AttributeServer(
id,
attributeName,
readAcl,
writeAcl,
schema,
writable,
!omitChanges,
timed,
initValue,
defaultValue,
datasource,
getter,
setter,
validator,
);
}
/**
* Base class for all attribute servers.
*/
export abstract class BaseAttributeServer<T> {
/**
* The value is undefined when getter/setter are used. But we still handle the version number here.
*/
protected value: T | undefined = undefined;
protected endpoint?: Endpoint;
readonly defaultValue: T;
#readAcl: AccessLevel | undefined;
#writeAcl: AccessLevel | undefined;
constructor(
readonly id: AttributeId,
readonly name: string,
readAcl: AccessLevel | undefined,
writeAcl: AccessLevel | undefined,
readonly schema: TlvSchema<T>,
readonly isWritable: boolean,
readonly isSubscribable: boolean,
readonly requiresTimedInteraction: boolean,
initValue: T,
defaultValue: T | undefined,
) {
this.#readAcl = readAcl;
this.#writeAcl = writeAcl;
try {
this.validateWithSchema(initValue);
this.value = initValue;
} catch (error) {
logger.warn(
`Attribute value to initialize for ${name} has an invalid value ${Diagnostic.json(
initValue,
)}. Restore to default ${Diagnostic.json(defaultValue)}`,
);
if (defaultValue === undefined) {
throw new ImplementationError(`Attribute value to initialize for ${name} cannot be undefined.`);
}
this.validateWithSchema(defaultValue);
this.value = defaultValue;
}
this.defaultValue = this.value;
}
get hasFabricSensitiveData() {
return false;
}
validateWithSchema(value: T) {
try {
this.schema.validate(value);
} catch (e) {
ValidationError.accept(e);
// Handle potential error cases where a custom validator is used.
e.message = `Validation error for attribute "${this.name}"${e.fieldName !== undefined ? ` in field ${e.fieldName}` : ""}: ${e.message}`;
throw e;
}
}
assignToEndpoint(endpoint: Endpoint) {
this.endpoint = endpoint;
}
/**
* Initialize the value of the attribute, used when a persisted value is set initially or when values needs to be
* adjusted before the Device gets announced. Do not use this method to change values when the device is in use!
*/
abstract init(value: T | undefined): void;
get writeAcl() {
return this.#writeAcl ?? AccessLevel.Operate; // ???
}
get readAcl() {
return this.#readAcl ?? AccessLevel.View; // ???
}
}
/**
* Attribute server class that handled fixed attribute values that never change and is the base class for other
* Attribute server types.
*/
export class FixedAttributeServer<T> extends BaseAttributeServer<T> {
readonly isFixed: boolean = true;
protected readonly getter: (
session?: Session,
endpoint?: Endpoint,
isFabricFiltered?: boolean,
message?: Message,
) => T;
constructor(
id: AttributeId,
name: string,
readAcl: AccessLevel | undefined,
writeAcl: AccessLevel | undefined,
schema: TlvSchema<T>,
isWritable: boolean,
isSubscribable: boolean,
requiresTimedInteraction: boolean,
initValue: T,
defaultValue: T | undefined,
protected readonly datasource: ClusterDatasource,
/**
* Optional getter function to handle special requirements or the data are stored in different places.
*
* @param session the session that is requesting the value (if any)
* @param endpoint the endpoint the cluster server of this attribute is assigned to
* @param isFabricFiltered whether the read request is fabric scoped or not
* @param message the wire message that initiated the request (if any)
*/
getter?: (session?: Session, endpoint?: Endpoint, isFabricFiltered?: boolean, message?: Message) => T,
) {
super(
id,
name,
readAcl,
writeAcl,
schema,
isWritable,
isSubscribable,
requiresTimedInteraction,
initValue,
defaultValue,
); // Fixed attributes do not change, so are not subscribable
if (getter === undefined) {
this.getter = () => {
if (this.value === undefined) {
// Should not happen
throw new InternalError(`Attribute value for attribute "${name}" is not initialized.`);
}
return this.value;
};
} else {
this.getter = getter;
}
}
/**
* Get the value of the attribute. This method is used by the Interaction model to read the value of the attribute
* and includes the ACL check. It should not be used locally in the code!
*
* If a getter is defined the value is determined by that getter method.
*/
get(session: Session, isFabricFiltered: boolean, message?: Message): T {
// TODO: check ACL
return this.getter(session, this.endpoint, isFabricFiltered, message);
}
/**
* Get the value of the attribute including the version number. This method is used by the Interaction model to read
* the value of the attribute and includes the ACL check. It should not be used locally in the code!
*
* If a getter is defined the value is determined by that getter method. The version number is always 0 for fixed
* attributes.
*/
getWithVersion(session: Session, isFabricFiltered: boolean, message?: Message) {
return { version: this.datasource.version, value: this.get(session, isFabricFiltered, message) };
}
/**
* Get the value of the attribute locally. This method should be used locally in the code and does not include the
* ACL check.
* If a getter is defined the value is determined by that getter method.
*/
getLocal(): T {
return this.getter(undefined, this.endpoint);
}
/**
* Initialize the value of the attribute, used when a persisted value is set initially or when values needs to be
* adjusted before the Device gets announced. Do not use this method to change values when the device is in use!
* If a getter or setter is defined the value must be undefined The version number must also be undefined.
*/
init(value: T | undefined) {
if (value === undefined) {
throw new InternalError(`Cannot initialize fixed attribute "${this.name}" with undefined value.`);
}
this.validateWithSchema(value);
this.value = value;
}
/**
* Add an internal listener that is called when the value of the attribute changes. The listener is called with the
* new value and the version number.
*/
addValueChangeListener(_listener: (value: T, version: number) => void) {
/** Fixed attributes do not change. */
}
/**
* Remove an internal listener.
*/
removeValueChangeListener(_listener: (value: T, version: number) => void) {
/** Fixed attributes do not change. */
}
/**
* Add an external listener that is called when the value of the attribute changes. The listener is called with the
* new value and the old value.
*/
addValueSetListener(_listener: (newValue: T, oldValue: T) => void) {
/** Fixed attributes do not change. */
}
/**
* Add an external listener that is called when the value of the attribute changes. The listener is called with the
* new value and the old value. This method is a convenient alias for addValueSetListener.
*/
subscribe(_listener: (newValue: T, oldValue: T) => void) {
/** Fixed attributes do not change. */
}
/**
* Remove an external listener.
*/
removeValueSetListener(_listener: (newValue: T, oldValue: T) => void) {
/** Fixed attributes do not change. */
}
}
/**
* Attribute server for normal attributes that can be read and written.
*/
export class AttributeServer<T> extends FixedAttributeServer<T> {
override readonly isFixed = false;
protected readonly valueChangeListeners = new Array<(value: T, version: number) => void>();
protected readonly valueSetListeners = new Array<(newValue: T, oldValue: T) => void>();
protected readonly setter: (value: T, session?: Session, endpoint?: Endpoint, message?: Message) => boolean;
protected readonly validator: (value: T, session?: Session, endpoint?: Endpoint) => void;
protected delayedChangeData?: DelayedChangeData = undefined;
constructor(
id: AttributeId,
name: string,
readAcl: AccessLevel | undefined,
writeAcl: AccessLevel | undefined,
schema: TlvSchema<T>,
isWritable: boolean,
isSubscribable: boolean,
requiresTimedInteraction: boolean,
initValue: T,
defaultValue: T | undefined,
datasource: ClusterDatasource,
getter?: (session?: Session, endpoint?: Endpoint, isFabricFiltered?: boolean, message?: Message) => T,
/**
* Optional setter function to handle special requirements or the data are stored in different places. If a
* setter method is used for a writable attribute, the getter method must be implemented as well. The method
* needs to return if the stored value has changed or not.
*
* @param value the value to be set.
* @param session the session that is requesting the value (if any).
* @param endpoint the endpoint the cluster server of this attribute is assigned to.
* @returns true if the value has changed, false otherwise.
*/
setter?: (value: T, session?: Session, endpoint?: Endpoint, message?: Message) => boolean,
/**
* Optional Validator function to handle special requirements for verification of stored data. The method should
* throw an error if the value is not valid. If a StatusResponseError is thrown this one is also returned to the
* client.
*
* If a setter is used then no validator should be used as the setter should handle the validation itself!
*
* @param value the value to be set.
* @param session the session that is requesting the value (if any).
* @param endpoint the endpoint the cluster server of this attribute is assigned to.
*/
validator?: (value: T, session?: Session, endpoint?: Endpoint) => void,
) {
if (
isWritable &&
(getter === undefined || setter === undefined) &&
!(getter === undefined && setter === undefined)
) {
throw new ImplementationError(
`Getter and setter must be implemented together for writeable attribute "${name}".`,
);
}
super(
id,
name,
readAcl,
writeAcl,
schema,
isWritable,
isSubscribable,
requiresTimedInteraction,
initValue,
defaultValue,
datasource,
getter,
);
if (setter === undefined) {
this.setter = value => {
const oldValue = this.value;
this.value = value;
return !isDeepEqual(value, oldValue);
};
} else {
this.setter = setter;
}
this.validator = (value, session, endpoint) => {
this.validateWithSchema(value);
if (validator !== undefined) {
validator(value, session, endpoint);
}
};
}
/**
* Initialize the value of the attribute, used when a persisted value is set initially or when values needs to be
* adjusted before the Device gets announced. Do not use this method to change values when the device is in use!
*/
override init(value: T | undefined) {
if (value === undefined) {
value = this.getter(undefined, this.endpoint);
}
if (value === undefined) {
throw new InternalError(`Cannot initialize attribute "${this.name}" with undefined value.`);
}
this.validator(value, undefined, this.endpoint);
this.value = value;
}
/**
* Set the value of the attribute. This method is used by the Interaction model to write the value of the attribute
* and includes the ACL check. It should not be used locally in the code!
*
* If a setter is defined this setter method is called to store the value.
*
* Listeners are called when the value changes (internal listeners) or in any case (external listeners).
*/
set(value: T, session: Session, message?: Message, delayChangeEvents = false) {
if (!this.isWritable) {
throw new StatusResponseError(`Attribute "${this.name}" is not writable.`, StatusCode.UnsupportedWrite);
}
this.setRemote(value, session, message, delayChangeEvents);
}
/**
* Method that contains the logic to set a value "from remote" (e.g. from a client).
*/
protected setRemote(value: T, session: Session, message?: Message, delayChangeEvents = false) {
this.processSet(value, session, message, delayChangeEvents);
this.value = value;
}
/**
* Set the value of the attribute locally. This method should be used locally in the code and does not include the
* ACL check.
*
* If a setter is defined this setter method is called to validate and store the value.
*
* Else if a validator is defined the value is validated before it is stored.
*
* Listeners are called when the value changes (internal listeners) or in any case (external listeners).
*/
setLocal(value: T) {
this.processSet(value, undefined);
this.value = value;
}
/**
* Helper Method to process the set of a value in a generic way. This method is used internally.
*/
protected processSet(value: T, session?: Session, message?: Message, delayChangeEvents = false) {
this.validator(value, session, this.endpoint);
const oldValue = this.getter(session, this.endpoint, undefined, message);
const valueChanged = this.setter(value, session, this.endpoint, message);
if (delayChangeEvents) {
this.delayedChangeData = {
oldValue: this.delayedChangeData?.oldValue ?? oldValue, // We keep the oldest value
newValue: value,
changed: !!this.delayedChangeData?.changed || valueChanged, // We combine the changed flag
};
logger.info(`Delay change for attribute "${this.name}" with value ${Diagnostic.json(value)}`);
} else {
this.handleVersionAndTriggerListeners(value, oldValue, valueChanged);
}
}
triggerDelayedChangeEvents() {
if (this.delayedChangeData !== undefined) {
const { oldValue, newValue, changed } = this.delayedChangeData;
this.delayedChangeData = undefined;
logger.info(`Trigger delayed change for attribute "${this.name}" with value ${Diagnostic.json(newValue)}`);
this.handleVersionAndTriggerListeners(newValue, oldValue, changed);
}
}
/**
* Helper Method to handle needed version increases and trigger the relevant listeners. This method is used
* internally.
*/
protected handleVersionAndTriggerListeners(value: T, oldValue: T | undefined, considerVersionChanged: boolean) {
if (considerVersionChanged) {
const version = this.datasource.increaseVersion();
this.valueChangeListeners.forEach(listener => listener(value, version));
}
if (oldValue !== undefined) {
this.valueSetListeners.forEach(listener => listener(value, oldValue));
}
}
/**
* When the value is handled by getter or setter methods and is changed by other processes this method can be used
* to notify the attribute server that the value has changed. This will increase the version number and trigger the
* listeners.
*
* ACL checks needs to be performed before calling this method.
*/
updated(session: SecureSession) {
const oldValue = this.value ?? this.defaultValue;
try {
this.value = this.get(session, false);
} catch (e) {
NoAssociatedFabricError.accept(e);
// Handle potential error cases where the session does not have a fabric assigned.
if (this.value === undefined) {
this.value = this.defaultValue;
}
}
this.handleVersionAndTriggerListeners(this.value, oldValue, true);
}
/**
* When the value is handled by getter or setter methods and is changed by other processes and no session from the
* originating process is known this method can be used to notify the attribute server that the value has changed.
* This will increase the version number and trigger the listeners.
*
* ACL checks needs to be performed before calling this method.
*/
updatedLocal() {
const oldValue = this.value ?? this.defaultValue;
this.value = this.getLocal();
this.handleVersionAndTriggerListeners(this.value, oldValue, true);
}
/**
* Add an internal listener that is called when the value of the attribute changes. The listener is called with the
* new value and the version number.
*/
override addValueChangeListener(listener: (value: T, version: number) => void) {
this.valueChangeListeners.push(listener);
}
/**
* Remove an internal listener.
*/
override removeValueChangeListener(listener: (value: T, version: number) => void) {
const entryIndex = this.valueChangeListeners.indexOf(listener);
if (entryIndex !== -1) {
this.valueChangeListeners.splice(entryIndex, 1);
}
}
/**
* Add an external listener that is called when the value of the attribute changes. The listener is called with the
* new value and the old value.
*/
override addValueSetListener(listener: (newValue: T, oldValue: T) => void) {
this.valueSetListeners.push(listener);
}
/**
* Add an external listener that is called when the value of the attribute changes. The listener is called with the
* new value and the old value. This method is a convenient alias for addValueSetListener.
*/
override subscribe(listener: (newValue: T, oldValue: T) => void) {
this.addValueSetListener(listener);
}
/**
* Remove an external listener.
*/
override removeValueSetListener(listener: (newValue: T, oldValue: T) => void) {
const entryIndex = this.valueSetListeners.indexOf(listener);
if (entryIndex !== -1) {
this.valueSetListeners.splice(entryIndex, 1);
}
}
}
/**
* Attribute server which is getting and setting the value for a defined fabric. The values are automatically persisted
* on fabric level if no custom getter or setter is defined.
*/
export class FabricScopedAttributeServer<T> extends AttributeServer<T> {
private readonly fabricSensitiveElementsToRemove = new Array<string>();
constructor(
id: AttributeId,
name: string,
readAcl: AccessLevel | undefined,
writeAcl: AccessLevel | undefined,
schema: TlvSchema<T>,
isWritable: boolean,
isSubscribable: boolean,
requiresTimedInteraction: boolean,
initValue: T,
defaultValue: T | undefined,
readonly cluster: Cluster<any, any, any, any, any>,
datasource: ClusterDatasource,
getter?: (session?: Session, endpoint?: Endpoint, isFabricFiltered?: boolean) => T,
setter?: (value: T, session?: Session, endpoint?: Endpoint, message?: Message) => boolean,
validator?: (value: T, session?: Session, endpoint?: Endpoint) => void,
) {
if (
isWritable &&
(getter === undefined || setter === undefined) &&
!(getter === undefined && setter === undefined)
) {
throw new ImplementationError(
`Getter and setter must be implemented together for writeable fabric scoped attribute "${name}".`,
);
}
if (getter === undefined) {
getter = (session, _endpoint, isFabricFiltered) => {
if (session === undefined)
throw new FabricScopeError(`Session is required for fabric scoped attribute ${name}`);
if (isFabricFiltered === true) {
SecureSession.assert(session);
return this.getLocalForFabric(session.associatedFabric);
} else {
const values = new Array<any>();
for (const fabric of datasource.fabrics) {
const value = this.getLocalForFabric(fabric);
if (!Array.isArray(value)) {
throw new FabricScopeError(
`Fabric scoped attribute "${name}" can only be read for all fabrics if they are arrays.`,
);
}
values.push(...value);
}
return values as T;
}
};
}
if (setter === undefined) {
setter = () => {
throw new ImplementationError("Legacy FabricScopedAttributeServer data set is not supported anymore.");
};
}
super(
id,
name,
readAcl,
writeAcl,
schema,
isWritable,
isSubscribable,
requiresTimedInteraction,
initValue,
defaultValue,
datasource,
getter,
setter,
validator,
);
this.#determineSensitiveFieldsToRemove();
}
#determineSensitiveFieldsToRemove() {
const clusterFromModel = MatterModel.standard.get(ClusterModel, this.cluster.id);
if (clusterFromModel === undefined) {
logger.debug(`${this.cluster.name}: Cluster for Fabric scoped element not found in Model, ignore`);
return;
}
const attributeFromModel = clusterFromModel.get(AttributeModel, this.id);
if (attributeFromModel === undefined) {
logger.debug(
`${this.cluster.name}.${this.id}: Attribute for Fabric scoped element not found in Model, ignore`,
);
return;
}
if (!attributeFromModel.fabricScoped) {
logger.debug(`${this.cluster.name}.${this.id}: Attribute is not Fabric scoped in model, ignore`);
return;
}
if (attributeFromModel.children.length !== 1) {
logger.debug(`${this.cluster.name}.${this.id}: Attribute has not exactly one child, ignore`);
return;
}
const type = attributeFromModel.children[0].type;
if (type === undefined) {
logger.debug(`${this.cluster.name}.${this.id}: Attribute field has no type, ignore`);
return;
}
const dataType = clusterFromModel.get(DatatypeModel, type);
if (dataType === undefined) {
logger.debug(`${this.cluster.name}.${this.id}: DataType ${type} not found in model, ignore`);
return;
}
dataType.children
.filter(field => field.fabricSensitive)
.forEach(field => this.fabricSensitiveElementsToRemove.push(camelize(field.name)));
}
override get hasFabricSensitiveData() {
return this.fabricSensitiveElementsToRemove.length > 0;
}
/**
* Sanitize the value of the attribute by removing fabric sensitive fields that do not belong to the
* associated fabric
*/
sanitizeFabricSensitiveFields(value: T, associatedFabric?: Fabric) {
if (this.fabricSensitiveElementsToRemove.length && Array.isArray(value)) {
// Get the associated Fabric Index or uses -1 when no Fabric is associated because this value will
// never be in the struct
const associatedFabricIndex = associatedFabric?.fabricIndex ?? -1;
return value.map(data => {
if (data[FabricIndexName] !== associatedFabricIndex) {
const result = { ...data };
this.fabricSensitiveElementsToRemove.forEach(fieldName => delete result[fieldName]);
return result;
}
return data;
});
}
return value;
}
/**
* Initialize the attribute with a value. Because the value is stored on fabric level this method only initializes
* the version number.
*/
override init(value: T | undefined) {
if (value !== undefined) {
throw new InternalError(`Cannot initialize fabric scoped attribute "${this.name}" with a value.`);
}
}
/**
* Fabric scoped enhancement of set to allow setting special fabricindex locally.
*/
override set(value: T, session: Session, message: Message, delayChangeEvents = false, preserveFabricIndex = false) {
if (!this.isWritable) {
throw new StatusResponseError(`Attribute "${this.name}" is not writable.`, StatusCode.UnsupportedWrite);
}
this.setRemote(value, session, message, delayChangeEvents, preserveFabricIndex);
}
/**
* Method that contains the logic to set a value "from remote" (e.g. from a client). For Fabric scoped attributes
* we need to inject the fabric index into the value.
*/
protected override setRemote(
value: T,
session: Session,
message: Message,
delayChangeEvents = false,
preserveFabricIndex = false,
) {
// Inject fabric index into structures in general if undefined, if set it will be used
value = this.schema.injectField(
value,
<number>FabricIndex.id,
session.associatedFabric.fabricIndex,
() => !preserveFabricIndex, // No one should send any index and if we simply SHALL ignore it, but internally we might need it
);
logger.info(
`Set remote value for fabric scoped attribute "${this.name}" to ${Diagnostic.json(value)} (delayed=${delayChangeEvents})`,
);
super.setRemote(value, session, message, delayChangeEvents);
}
/**
* Set Local is not allowed for fabric scoped attributes. Use setLocalForFabric instead.
*/
override setLocal(_value: T) {
throw new FabricScopeError(
`Fabric scoped attribute "${this.name}" can only be set locally by providing a Fabric. Use setLocalForFabric instead.`,
);
}
/**
* Set the value of the attribute locally for a fabric. This method should be used locally in the code and does not
* include the ACL check.
* If a setter is defined this method cannot be used!
* If a validator is defined the value is validated before it is stored.
* Listeners are called when the value changes (internal listeners) or in any case (external listeners).
*/
setLocalForFabric(_value: T, _fabric: Fabric) {
throw new ImplementationError("Legacy FabricScopedAttributeServer data write is not supported anymore.");
}
/**
* When the value is handled by getter or setter methods and is changed by other processes and no session from the
* originating process is known this method can be used to notify the attribute server that the value has changed.
* This will increase the version number and trigger the listeners.
* ACL checks needs to be performed before calling this method.
*/
updatedLocalForFabric(fabric: Fabric) {
const oldValue = this.value ?? this.defaultValue;
try {
this.value = this.getLocalForFabric(fabric);
} catch (e) {
FabricScopeError.accept(e);
if (this.value === undefined) {
this.value = this.defaultValue;
}
}
this.handleVersionAndTriggerListeners(this.value, oldValue, true);
}
/**
* Get the value of the attribute locally for a special Fabric. This method should be used locally in the code and
* does not include the ACL check.
* If a getter is defined this method returns an error and the value should be retrieved directly internally.
*/
getLocalForFabric(_fabric: Fabric): T {
throw new ImplementationError("Legacy FabricScopedAttributeServer data read is not supported anymore.");
}
}