Coverage for filip/models/ngsi_v2/iot.py: 88%

183 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2025-02-19 11:48 +0000

1""" 

2Module contains models for accessing and interaction with FIWARE's IoT-Agents. 

3""" 

4 

5from __future__ import annotations 

6import logging 

7import itertools 

8import warnings 

9from enum import Enum 

10from typing import Any, Dict, Optional, List, Union 

11import pytz 

12from pydantic import ( 

13 field_validator, 

14 model_validator, 

15 ConfigDict, 

16 BaseModel, 

17 Field, 

18 AnyHttpUrl, 

19) 

20from filip.models.base import NgsiVersion, DataType 

21from filip.models.ngsi_v2.base import ( 

22 BaseAttribute, 

23 BaseValueAttribute, 

24 BaseNameAttribute, 

25) 

26from filip.utils.validators import ( 

27 validate_fiware_datatype_string_protect, 

28 validate_fiware_datatype_standard, 

29 validate_jexl_expression, 

30 validate_expression_language, 

31 validate_fiware_attribute_name_regex, 

32) 

33 

34logger = logging.getLogger() 

35 

36 

37class ExpressionLanguage(str, Enum): 

38 """ 

39 Options for expression language 

40 """ 

41 

42 LEGACY = "legacy" 

43 JEXL = "jexl" 

44 

45 

46class PayloadProtocol(str, Enum): 

47 """ 

48 Options for payload protocols 

49 """ 

50 

51 IOTA_JSON = "IoTA-JSON" 

52 IOTA_UL = "PDI-IoTA-UltraLight" 

53 LORAWAN = "LoRaWAN" 

54 

55 

56class TransportProtocol(str, Enum): 

57 """ 

58 Options for transport protocols 

59 """ 

60 

61 MQTT = "MQTT" 

62 AMQP = "AMQP" 

63 HTTP = "HTTP" 

64 

65 

66class IoTABaseAttribute(BaseAttribute, BaseNameAttribute): 

67 """ 

68 Base model for device attributes 

69 """ 

70 

71 expression: Optional[str] = Field( 

72 default=None, 

73 description="indicates that the value of the target attribute will " 

74 "not be the plain value or the measurement, but an " 

75 "expression based on a combination of the reported values. " 

76 "See the Expression Language definition for details " 

77 "(https://iotagent-node-lib.readthedocs.io/en/latest/" 

78 "api.html#expression-language-support)", 

79 ) 

80 entity_name: Optional[str] = Field( 

81 default=None, 

82 description="entity_name: the presence of this attribute indicates " 

83 "that the value will not be stored in the original device " 

84 "entity but in a new entity with an ID given by this " 

85 "attribute. The type of this additional entity can be " 

86 "configured with the entity_type attribute. If no type is " 

87 "configured, the device entity type is used instead. " 

88 "Entity names can be defined as expressions, using the " 

89 "Expression Language definition " 

90 "(https://iotagent-node-lib.readthedocs.io/en/latest/" 

91 "api.html#expression-language-support). Allowed characters are" 

92 " the ones in the plain ASCII set, except the following " 

93 "ones: control characters, whitespace, &, ?, / and #.", 

94 max_length=256, 

95 min_length=1, 

96 ) 

97 valid_entity_name = field_validator("entity_name")( 

98 validate_fiware_datatype_standard 

99 ) 

100 entity_type: Optional[str] = Field( 

101 default=None, 

102 description="configures the type of an alternative entity. " 

103 "Allowed characters " 

104 "are the ones in the plain ASCII set, except the following " 

105 "ones: control characters, whitespace, &, ?, / and #.", 

106 max_length=256, 

107 min_length=1, 

108 ) 

109 valid_entity_type = field_validator("entity_type")( 

110 validate_fiware_datatype_standard 

111 ) 

112 reverse: Optional[str] = Field( 

113 default=None, 

114 description="add bidirectionality expressions to the attribute. See " 

115 "the bidirectionality transformation plugin in the " 

116 "Data Mapping Plugins section for details. " 

117 "(https://iotagent-node-lib.readthedocs.io/en/latest/api/" 

118 "index.html#data-mapping-plugins)", 

119 ) 

120 

121 def __eq__(self, other): 

122 if isinstance(other, BaseAttribute): 

123 return self.name == other.name 

124 else: 

125 return self.model_dump() == other 

126 

127 

128class DeviceAttribute(IoTABaseAttribute): 

129 """ 

130 Model for active device attributes 

131 """ 

132 

133 object_id: Optional[str] = Field( 

134 default=None, description="name of the attribute as coming from the device." 

135 ) 

136 

137 

138class LazyDeviceAttribute(BaseNameAttribute): 

139 """ 

140 Model for lazy device attributes 

141 """ 

142 

143 type: Union[DataType, str] = Field( 

144 default=DataType.TEXT, 

145 description="The attribute type represents the NGSI value type of the " 

146 "attribute value. Note that FIWARE NGSI has its own type " 

147 "system for attribute values, so NGSI value types are not " 

148 "the same as JSON types. Allowed characters " 

149 "are the ones in the plain ASCII set, except the following " 

150 "ones: control characters, whitespace, &, ?, / and #.", 

151 max_length=256, 

152 min_length=1, 

153 ) 

154 valid_type = field_validator("type")(validate_fiware_datatype_string_protect) 

155 

156 

157class DeviceCommand(BaseModel): 

158 """ 

159 Model for commands 

160 """ 

161 

162 name: str = Field( 

163 description="ID of the attribute in the target entity in the " 

164 "Context Broker. Allowed characters " 

165 "are the ones in the plain ASCII set, except the following " 

166 "ones: control characters, whitespace, &, ?, / and #.", 

167 max_length=256, 

168 min_length=1, 

169 ) 

170 valid_name = field_validator("name")(validate_fiware_attribute_name_regex) 

171 type: Union[DataType, str] = Field( 

172 description="name of the type of the attribute in the target entity. ", 

173 default=DataType.COMMAND, 

174 ) 

175 

176 

177class StaticDeviceAttribute(IoTABaseAttribute, BaseValueAttribute): 

178 """ 

179 Model for static device attributes 

180 """ 

181 

182 pass 

183 

184 

185class ServiceGroup(BaseModel): 

186 """ 

187 Model for device service group. 

188 https://iotagent-node-lib.readthedocs.io/en/latest/api/index.html#service-group-api 

189 """ 

190 

191 service: Optional[str] = Field( 

192 default=None, description="ServiceGroup of the devices of this type" 

193 ) 

194 subservice: Optional[str] = Field( 

195 default=None, 

196 description="Subservice of the devices of this type.", 

197 pattern="^/", 

198 ) 

199 resource: str = Field( 

200 description="string representing the Southbound resource that will be " 

201 "used to assign a type to a device (e.g.: pathname in the " 

202 "southbound port)." 

203 ) 

204 apikey: str = Field( 

205 description="API Key string. It is a key used for devices belonging " 

206 "to this service_group. If " 

207 ", service_group does not use " 

208 "apikey, but it must be specified." 

209 ) 

210 timestamp: Optional[bool] = Field( 

211 default=None, 

212 description="Optional flag about whether or not to add the TimeInstant " 

213 "attribute to the device entity created, as well as a " 

214 "TimeInstant metadata to each attribute, with the current " 

215 "timestamp. With NGSI-LD, the Standard observedAt " 

216 "property-of-a-property is created instead.", 

217 ) 

218 entity_type: Optional[str] = Field( 

219 default=None, 

220 description="name of the Entity type to assign to the group. " 

221 "Allowed characters " 

222 "are the ones in the plain ASCII set, except the following " 

223 "ones: control characters, whitespace, &, ?, / and #.", 

224 max_length=256, 

225 min_length=1, 

226 ) 

227 valid_entity_type = field_validator("entity_type")( 

228 validate_fiware_datatype_standard 

229 ) 

230 trust: Optional[str] = Field( 

231 default=None, 

232 description="trust token to use for secured access to the " 

233 "Context Broker for this type of devices (optional; only " 

234 "needed for secured scenarios).", 

235 ) 

236 cbHost: Optional[AnyHttpUrl] = Field( 

237 default=None, 

238 description="Context Broker connection information. This options can " 

239 "be used to override the global ones for specific types of " 

240 "devices.", 

241 ) 

242 

243 @field_validator("cbHost") 

244 @classmethod 

245 def validate_cbHost(cls, value): 

246 """ 

247 convert cbHost to str 

248 Returns: 

249 timezone 

250 """ 

251 return str(value) if value else value 

252 

253 lazy: Optional[List[LazyDeviceAttribute]] = Field( 

254 default=[], 

255 description="list of common lazy attributes of the device. For each " 

256 "attribute, its name and type must be provided.", 

257 ) 

258 commands: Optional[List[DeviceCommand]] = Field( 

259 default=[], 

260 description="list of common commands attributes of the device. For each " 

261 "attribute, its name and type must be provided, additional " 

262 "metadata is optional", 

263 ) 

264 attributes: Optional[List[DeviceAttribute]] = Field( 

265 default=[], 

266 description="list of common commands attributes of the device. For " 

267 "each attribute, its name and type must be provided, " 

268 "additional metadata is optional.", 

269 ) 

270 static_attributes: Optional[List[StaticDeviceAttribute]] = Field( 

271 default=[], 

272 description="this attributes will be added to all the entities of this " 

273 "group 'as is', additional metadata is optional.", 

274 ) 

275 internal_attributes: Optional[List[Dict[str, Any]]] = Field( 

276 default=[], 

277 description="optional section with free format, to allow specific " 

278 "IoT Agents to store information along with the devices " 

279 "in the Device Registry.", 

280 ) 

281 expressionLanguage: Optional[ExpressionLanguage] = Field( 

282 default=ExpressionLanguage.JEXL, 

283 description="optional boolean value, to set expression language used " 

284 "to compute expressions, possible values are: " 

285 "legacy or jexl, but legacy is deprecated. If it is set None, jexl is used.", 

286 ) 

287 valid_expressionLanguage = field_validator("expressionLanguage")( 

288 validate_expression_language 

289 ) 

290 explicitAttrs: Optional[bool] = Field( 

291 default=False, 

292 description="optional boolean value, to support selective ignore " 

293 "of measures so that IOTA does not progress. If not " 

294 "specified default is false.", 

295 ) 

296 autoprovision: Optional[bool] = Field( 

297 default=True, 

298 description="optional boolean: If false, autoprovisioned devices " 

299 "(i.e. devices that are not created with an explicit " 

300 "provision operation but when the first measure arrives) " 

301 "are not allowed in this group. " 

302 "Default (in the case of omitting the field) is true.", 

303 ) 

304 ngsiVersion: Optional[NgsiVersion] = Field( 

305 default="v2", 

306 description="optional string value used in mixed mode to switch between" 

307 " NGSI-v2 and NGSI-LD payloads. Possible values are: " 

308 "v2 or ld. The default is v2. When not running in mixed " 

309 "mode, this field is ignored.", 

310 ) 

311 defaultEntityNameConjunction: Optional[str] = Field( 

312 default=None, 

313 description="optional string value to set default conjunction string " 

314 "used to compose a default entity_name when is not " 

315 "provided at device provisioning time.", 

316 ) 

317 

318 

319class DeviceSettings(BaseModel): 

320 """ 

321 Model for iot device settings 

322 """ 

323 

324 model_config = ConfigDict(validate_assignment=True) 

325 timezone: Optional[str] = Field( 

326 default="Europe/London", description="Time zone of the sensor if it has any" 

327 ) 

328 timestamp: Optional[bool] = Field( 

329 default=None, 

330 description="Optional flag about whether or not to add the TimeInstant " 

331 "attribute to the device entity created, as well as a " 

332 "TimeInstant metadata to each attribute, with the current " 

333 "timestamp. With NGSI-LD, the Standard observedAt " 

334 "property-of-a-property is created instead.", 

335 ) 

336 apikey: Optional[str] = Field( 

337 default=None, 

338 description="Optional Apikey key string to use instead of group apikey", 

339 ) 

340 endpoint: Optional[AnyHttpUrl] = Field( 

341 default=None, 

342 description="Endpoint where the device is going to receive commands, " 

343 "if any.", 

344 ) 

345 protocol: Optional[Union[PayloadProtocol, str]] = Field( 

346 default=None, 

347 description="Name of the device protocol, for its use with an " "IoT Manager.", 

348 ) 

349 transport: Optional[Union[TransportProtocol, str]] = Field( 

350 default=None, 

351 description="Name of the device transport protocol, for the IoT Agents " 

352 "with multiple transport protocols.", 

353 ) 

354 expressionLanguage: Optional[ExpressionLanguage] = Field( 

355 default=ExpressionLanguage.JEXL, 

356 description="optional boolean value, to set expression language used " 

357 "to compute expressions, possible values are: " 

358 "legacy or jexl, but legacy is deprecated. If it is set None, jexl is used.", 

359 ) 

360 valid_expressionLanguage = field_validator("expressionLanguage")( 

361 validate_expression_language 

362 ) 

363 explicitAttrs: Optional[bool] = Field( 

364 default=False, 

365 description="optional boolean value, to support selective ignore " 

366 "of measures so that IOTA does not progress. If not " 

367 "specified default is false.", 

368 ) 

369 

370 

371class Device(DeviceSettings): 

372 """ 

373 Model for iot devices. 

374 https://iotagent-node-lib.readthedocs.io/en/latest/api/index.html#device-api 

375 """ 

376 

377 model_config = ConfigDict(validate_default=True, validate_assignment=True) 

378 device_id: str = Field( 

379 description="Device ID that will be used to identify the device" 

380 ) 

381 service: Optional[str] = Field( 

382 default=None, 

383 description="Name of the service the device belongs to " 

384 "(will be used in the fiware-service header).", 

385 max_length=50, 

386 ) 

387 service_path: Optional[str] = Field( 

388 default="/", 

389 description="Name of the subservice the device belongs to " 

390 "(used in the fiware-servicepath header).", 

391 max_length=51, 

392 pattern="^/", 

393 ) 

394 entity_name: str = Field( 

395 description="Name of the entity representing the device in " 

396 "the Context Broker Allowed characters " 

397 "are the ones in the plain ASCII set, except the following " 

398 "ones: control characters, whitespace, &, ?, / and #.", 

399 max_length=256, 

400 min_length=1, 

401 ) 

402 valid_entity_name = field_validator("entity_name")( 

403 validate_fiware_datatype_standard 

404 ) 

405 entity_type: str = Field( 

406 description="Type of the entity in the Context Broker. " 

407 "Allowed characters " 

408 "are the ones in the plain ASCII set, except the following " 

409 "ones: control characters, whitespace, &, ?, / and #.", 

410 max_length=256, 

411 min_length=1, 

412 ) 

413 valid_entity_type = field_validator("entity_type")( 

414 validate_fiware_datatype_standard 

415 ) 

416 lazy: List[LazyDeviceAttribute] = Field( 

417 default=[], description="List of lazy attributes of the device" 

418 ) 

419 commands: List[DeviceCommand] = Field( 

420 default=[], description="List of commands of the device" 

421 ) 

422 attributes: List[DeviceAttribute] = Field( 

423 default=[], description="List of active attributes of the device" 

424 ) 

425 static_attributes: Optional[List[StaticDeviceAttribute]] = Field( 

426 default=[], 

427 description="List of static attributes to append to the entity. All the" 

428 " updateContext requests to the CB will have this set of " 

429 "attributes appended.", 

430 ) 

431 internal_attributes: Optional[List[Dict[str, Any]]] = Field( 

432 default=[], 

433 description="List of internal attributes with free format for specific " 

434 "IoT Agent configuration", 

435 ) 

436 ngsiVersion: NgsiVersion = Field( 

437 default=NgsiVersion.v2, 

438 description="optional string value used in mixed mode to switch between" 

439 " NGSI-v2 and NGSI-LD payloads. Possible values are: " 

440 "v2 or ld. The default is v2. When not running in " 

441 "mixed mode, this field is ignored.", 

442 ) 

443 

444 @field_validator("timezone") 

445 @classmethod 

446 def validate_timezone(cls, value): 

447 """ 

448 validate timezone 

449 Returns: 

450 timezone 

451 """ 

452 assert value in pytz.all_timezones 

453 return value 

454 

455 @model_validator(mode="after") 

456 def validate_device_attributes_expression(self): 

457 """ 

458 Validates device attributes expressions based on the expression language (JEXL or Legacy, where Legacy is 

459 deprecated). 

460 

461 Args: 

462 self: The Device instance. 

463 

464 Returns: 

465 The Device instance after validation. 

466 """ 

467 if self.expressionLanguage == ExpressionLanguage.JEXL: 

468 for attribute in self.attributes: 

469 if attribute.expression: 

470 validate_jexl_expression( 

471 attribute.expression, attribute.name, self.device_id 

472 ) 

473 elif self.expressionLanguage == ExpressionLanguage.LEGACY: 

474 warnings.warn( 

475 f"No validation for legacy expression language of Device {self.device_id}." 

476 ) 

477 

478 return self 

479 

480 @model_validator(mode="after") 

481 def validate_duplicated_device_attributes(self): 

482 """ 

483 Check whether device has identical attributes 

484 Args: 

485 self: dict of Device instance. 

486 

487 Returns: 

488 The dict of Device instance after validation. 

489 """ 

490 for i, attr in enumerate(self.attributes): 

491 for other_attr in self.attributes[:i] + self.attributes[i + 1 :]: 

492 if attr.model_dump() == other_attr.model_dump(): 

493 raise ValueError(f"Duplicated attributes found: {attr.name}") 

494 return self 

495 

496 @model_validator(mode="after") 

497 def validate_device_attributes_name_object_id(self): 

498 """ 

499 Validate the device regarding the behavior with devices attributes. 

500 According to https://iotagent-node-lib.readthedocs.io/en/latest/api.html and 

501 based on our best practice, following rules are checked 

502 - name is required, but not necessarily unique 

503 - object_id is not required, if given must be unique, i.e. not equal to any 

504 existing object_id and name 

505 Args: 

506 self: dict of Device instance. 

507 

508 Returns: 

509 The dict of Device instance after validation. 

510 """ 

511 for i, attr in enumerate(self.attributes): 

512 for other_attr in self.attributes[:i] + self.attributes[i + 1 :]: 

513 if ( 

514 attr.object_id 

515 and other_attr.object_id 

516 and attr.object_id == other_attr.object_id 

517 ): 

518 raise ValueError(f"object_id {attr.object_id} is not unique") 

519 if attr.object_id and attr.object_id == other_attr.name: 

520 raise ValueError(f"object_id {attr.object_id} is not unique") 

521 return self 

522 

523 def get_attribute( 

524 self, attribute_name: str 

525 ) -> Union[ 

526 DeviceAttribute, LazyDeviceAttribute, StaticDeviceAttribute, DeviceCommand 

527 ]: 

528 """ 

529 

530 Args: 

531 attribute_name: 

532 

533 Returns: 

534 

535 """ 

536 for attribute in itertools.chain( 

537 self.attributes, 

538 self.lazy, 

539 self.static_attributes, 

540 self.internal_attributes, 

541 self.commands, 

542 ): 

543 if attribute.name == attribute_name: 

544 return attribute 

545 msg = ( 

546 f"Device: {self.device_id}: Could not " 

547 f"find attribute with name {attribute_name}" 

548 ) 

549 logger.error(msg) 

550 raise KeyError(msg) 

551 

552 def add_attribute( 

553 self, 

554 attribute: Union[ 

555 DeviceAttribute, LazyDeviceAttribute, StaticDeviceAttribute, DeviceCommand 

556 ], 

557 update: bool = False, 

558 ) -> None: 

559 """ 

560 

561 Args: 

562 attribute: 

563 update (bool): If 'True' and attribute does already exists tries 

564 to update the attribute if not 

565 Returns: 

566 None 

567 """ 

568 try: 

569 if type(attribute) == DeviceAttribute: 

570 if attribute.model_dump(exclude_none=True) in [ 

571 attr.model_dump(exclude_none=True) for attr in self.attributes 

572 ]: 

573 raise ValueError 

574 

575 self.attributes.append(attribute) 

576 self.__setattr__(name="attributes", value=self.attributes) 

577 elif type(attribute) == LazyDeviceAttribute: 

578 if attribute in self.lazy: 

579 raise ValueError 

580 

581 self.lazy.append(attribute) 

582 self.__setattr__(name="lazy", value=self.lazy) 

583 elif type(attribute) == StaticDeviceAttribute: 

584 if attribute in self.static_attributes: 

585 raise ValueError 

586 

587 self.static_attributes.append(attribute) 

588 self.__setattr__(name="static_attributes", value=self.static_attributes) 

589 elif type(attribute) == DeviceCommand: 

590 if attribute in self.commands: 

591 raise ValueError 

592 

593 self.commands.append(attribute) 

594 self.__setattr__(name="commands", value=self.commands) 

595 else: 

596 raise ValueError 

597 except ValueError: 

598 if update: 

599 self.update_attribute(attribute, append=False) 

600 logger.warning( 

601 "Device: %s: Attribute already " "exists. Will update: \n %s", 

602 self.device_id, 

603 attribute.model_dump_json(indent=2), 

604 ) 

605 else: 

606 logger.error( 

607 "Device: %s: Attribute already " "exists: \n %s", 

608 self.device_id, 

609 attribute.model_dump_json(indent=2), 

610 ) 

611 raise 

612 

613 def update_attribute( 

614 self, 

615 attribute: Union[ 

616 DeviceAttribute, LazyDeviceAttribute, StaticDeviceAttribute, DeviceCommand 

617 ], 

618 append: bool = False, 

619 ) -> None: 

620 """ 

621 Updates existing device attribute 

622 

623 Args: 

624 attribute: Attribute to add to device configuration 

625 append (bool): Adds attribute if not exist 

626 

627 Returns: 

628 None 

629 """ 

630 try: 

631 if type(attribute) == DeviceAttribute: 

632 idx = self.attributes.index(attribute) 

633 self.attributes[idx].model_dump().update(attribute.model_dump()) 

634 elif type(attribute) == LazyDeviceAttribute: 

635 idx = self.lazy.index(attribute) 

636 self.lazy[idx].model_dump().update(attribute.model_dump()) 

637 elif type(attribute) == StaticDeviceAttribute: 

638 idx = self.static_attributes.index(attribute) 

639 self.static_attributes[idx].model_dump().update(attribute.model_dump()) 

640 elif type(attribute) == DeviceCommand: 

641 idx = self.commands.index(attribute) 

642 self.commands[idx].model_dump().update(attribute.model_dump()) 

643 except ValueError: 

644 if append: 

645 logger.warning( 

646 "Device: %s: Could not find " "attribute: \n %s", 

647 self.device_id, 

648 attribute.model_dump_json(indent=2), 

649 ) 

650 self.add_attribute(attribute=attribute) 

651 else: 

652 msg = ( 

653 f"Device: {self.device_id}: Could not find " 

654 f"attribute: \n {attribute.model_dump_json(indent=2)}" 

655 ) 

656 raise KeyError(msg) 

657 

658 def delete_attribute( 

659 self, 

660 attribute: Union[ 

661 DeviceAttribute, LazyDeviceAttribute, StaticDeviceAttribute, DeviceCommand 

662 ], 

663 ): 

664 """ 

665 Deletes attribute from device 

666 Args: 

667 attribute: () 

668 

669 Returns: 

670 

671 """ 

672 try: 

673 if type(attribute) == DeviceAttribute: 

674 self.attributes.remove(attribute) 

675 elif type(attribute) == LazyDeviceAttribute: 

676 self.lazy.remove(attribute) 

677 elif type(attribute) == StaticDeviceAttribute: 

678 self.static_attributes.remove(attribute) 

679 elif type(attribute) == DeviceCommand: 

680 self.commands.remove(attribute) 

681 else: 

682 raise ValueError 

683 except ValueError: 

684 logger.warning( 

685 "Device: %s: Could not delete " "attribute: \n %s", 

686 self.device_id, 

687 attribute.model_dump_json(indent=2), 

688 ) 

689 raise 

690 

691 logger.info( 

692 "Device: %s: Attribute deleted! \n %s", 

693 self.device_id, 

694 attribute.model_dump_json(indent=2), 

695 ) 

696 

697 def get_command(self, command_name: str): 

698 """ 

699 Short for self.get_attributes 

700 Args: 

701 command_name (str): 

702 Returns: 

703 

704 """ 

705 return self.get_attribute(attribute_name=command_name) 

706 

707 def add_command(self, command: DeviceCommand, update: bool = False): 

708 """ 

709 Short for self.add_attribute 

710 Args: 

711 command (DeviceCommand): 

712 update (bool): Update command if it already exists 

713 Returns: 

714 """ 

715 self.add_attribute(attribute=command, update=update) 

716 

717 def update_command(self, command: DeviceCommand, append: bool = False): 

718 """ 

719 Short for self.update_attribute 

720 Args: 

721 command: 

722 append: 

723 Returns: 

724 """ 

725 self.update_attribute(attribute=command, append=append) 

726 

727 def delete_command(self, command: DeviceCommand): 

728 """ 

729 Short for self.delete_attribute 

730 Args: 

731 command: 

732 

733 Returns: 

734 None 

735 """ 

736 self.delete_attribute(attribute=command)