• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pronovic / smartapp-sdk / 18200778862

02 Oct 2025 05:32PM UTC coverage: 98.509%. Remained the same
18200778862

push

github

pronovic
Release v0.9.1

793 of 805 relevant lines covered (98.51%)

3.83 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

99.8
/src/smartapp/interface.py
1
# vim: set ft=python ts=4 sw=4 expandtab:
2

3
"""
4
Classes that are part of the SmartApp interface.
5
"""
6

7
# For lifecycle class definitions, see:
8
#
9
#   https://developer.smartthings.com/docs/connected-services/lifecycles/
10
#   https://developer.smartthings.com/docs/connected-services/configuration/
11
#
12
# There is not any public documentation about event structure, only the Javascript
13
# reference implementation here:
14
#
15
#   https://github.com/SmartThingsCommunity/smartapp-sdk-nodejs/blob/f1ef97ec9c6dc270ba744197b842c6632c778987/lib/lifecycle-events.d.ts
16
#
17
# However, as of this writitng, even that reference implementation is not fully up-to-date
18
# with the JSON that is being returned for some events I have examined in my testing.
19
#
20
# I previously had some access to private documentation showing all of the attributes.
21
# However, that documentation did not always make it clear which attributes would always
22
# be included and which were optional.  As compromise, I have decided to maintain the
23
# actual events as dicts rather than true objects.  See further discussion below by
24
# the Event class.
25

26
from abc import ABC, abstractmethod
4✔
27
from collections.abc import Callable, Mapping
4✔
28
from enum import Enum, StrEnum
4✔
29
from typing import Any, assert_never
4✔
30

31
from arrow import Arrow
4✔
32
from attrs import field, frozen
4✔
33

34
AUTHORIZATION_HEADER = "authorization"
4✔
35
CORRELATION_ID_HEADER = "x-st-correlation"
4✔
36
DATE_HEADER = "date"
4✔
37

38

39
class LifecyclePhase(Enum):
4✔
40
    """Lifecycle phases."""
41

42
    CONFIRMATION = "CONFIRMATION"
4✔
43
    CONFIGURATION = "CONFIGURATION"
4✔
44
    INSTALL = "INSTALL"
4✔
45
    UPDATE = "UPDATE"
4✔
46
    UNINSTALL = "UNINSTALL"
4✔
47
    OAUTH_CALLBACK = "OAUTH_CALLBACK"
4✔
48
    EVENT = "EVENT"
4✔
49

50

51
class ConfigValueType(Enum):
4✔
52
    """Types of config values."""
53

54
    DEVICE = "DEVICE"
4✔
55
    STRING = "STRING"
4✔
56

57

58
class ConfigPhase(Enum):
4✔
59
    """Sub-phases within the CONFIGURATION phase."""
60

61
    INITIALIZE = "INITIALIZE"
4✔
62
    PAGE = "PAGE"
4✔
63

64

65
class ConfigSettingType(Enum):
4✔
66
    """Types of config settings."""
67

68
    DEVICE = "DEVICE"
4✔
69
    TEXT = "TEXT"
4✔
70
    BOOLEAN = "BOOLEAN"
4✔
71
    ENUM = "ENUM"
4✔
72
    LINK = "LINK"
4✔
73
    PAGE = "PAGE"
4✔
74
    IMAGE = "IMAGE"
4✔
75
    ICON = "ICON"
4✔
76
    TIME = "TIME"
4✔
77
    PARAGRAPH = "PARAGRAPH"
4✔
78
    EMAIL = "EMAIL"
4✔
79
    DECIMAL = "DECIMAL"
4✔
80
    NUMBER = "NUMBER"
4✔
81
    PHONE = "PHONE"
4✔
82
    OAUTH = "OAUTH"
4✔
83

84

85
class EventType(Enum):
4✔
86
    """Supported event types."""
87

88
    DEVICE_COMMANDS_EVENT = "DEVICE_COMMANDS_EVENT"
4✔
89
    DEVICE_EVENT = "DEVICE_EVENT"
4✔
90
    DEVICE_HEALTH_EVENT = "DEVICE_HEALTH_EVENT"
4✔
91
    DEVICE_LIFECYCLE_EVENT = "DEVICE_LIFECYCLE_EVENT"
4✔
92
    HUB_HEALTH_EVENT = "HUB_HEALTH_EVENT"
4✔
93
    INSTALLED_APP_LIFECYCLE_EVENT = "INSTALLED_APP_LIFECYCLE_EVENT"
4✔
94
    MODE_EVENT = "MODE_EVENT"
4✔
95
    SCENE_LIFECYCLE_EVENT = "SCENE_LIFECYCLE_EVENT"
4✔
96
    SECURITY_ARM_STATE_EVENT = "SECURITY_ARM_STATE_EVENT"
4✔
97
    TIMER_EVENT = "TIMER_EVENT"
4✔
98
    WEATHER_EVENT = "WEATHER_EVENT"
4✔
99

100

101
class SubscriptionType(Enum):
4✔
102
    """Supported subscription types."""
103

104
    DEVICE = "DEVICE"
4✔
105
    CAPABILITY = "CAPABILITY"
4✔
106
    MODE = "MODE"
4✔
107
    DEVICE_LIFECYCLE = "DEVICE_LIFECYCLE"
4✔
108
    DEVICE_HEALTH = "DEVICE_HEALTH"
4✔
109
    SECURITY_ARM_STATE = "SECURITY_ARM_STATE"
4✔
110
    HUB_HEALTH = "HUB_HEALTH"
4✔
111
    SCENE_LIFECYCLE = "SCENE_LIFECYCLE"
4✔
112

113

114
class BooleanValue(StrEnum):
4✔
115
    """String boolean values."""
116

117
    TRUE = "true"
4✔
118
    FALSE = "false"
4✔
119

120

121
@frozen(kw_only=True)
4✔
122
class AbstractRequest(ABC):
4✔
123
    """Abstract parent class for all types of lifecycle requests."""
124

125
    lifecycle: LifecyclePhase
3✔
126
    execution_id: str
3✔
127
    locale: str
3✔
128
    version: str
3✔
129

130

131
@frozen(kw_only=True)
4✔
132
class AbstractSetting(ABC):
4✔
133
    """Abstract parent class for all types of config settings."""
134

135
    id: str
3✔
136
    name: str
3✔
137
    description: str
3✔
138
    required: bool | None = False
4✔
139

140

141
@frozen(kw_only=True)
4✔
142
class DeviceSetting(AbstractSetting):
4✔
143
    """A DEVICE setting."""
144

145
    type: ConfigSettingType = ConfigSettingType.DEVICE
4✔
146
    multiple: bool
3✔
147
    capabilities: list[str]  # note that this is treated as AND - you'll get devices that have all capabilities
3✔
148
    permissions: list[str]
3✔
149

150

151
@frozen(kw_only=True)
4✔
152
class TextSetting(AbstractSetting):
4✔
153
    """A TEXT setting."""
154

155
    type: ConfigSettingType = ConfigSettingType.TEXT
4✔
156
    default_value: str
3✔
157

158

159
@frozen(kw_only=True)
4✔
160
class BooleanSetting(AbstractSetting):
4✔
161
    """A BOOLEAN setting."""
162

163
    type: ConfigSettingType = ConfigSettingType.BOOLEAN
4✔
164
    default_value: BooleanValue
3✔
165

166

167
@frozen(kw_only=True)
4✔
168
class EnumOption:
4✔
169
    """An option within an ENUM setting"""
170

171
    id: str
3✔
172
    name: str
3✔
173

174

175
@frozen(kw_only=True)
4✔
176
class EnumOptionGroup:
4✔
177
    """A group of options within an ENUM setting"""
178

179
    name: str
3✔
180
    options: list[EnumOption]
3✔
181

182

183
@frozen(kw_only=True)
4✔
184
class EnumSetting(AbstractSetting):
4✔
185
    """An ENUM setting."""
186

187
    type: ConfigSettingType = ConfigSettingType.ENUM
4✔
188
    multiple: bool
3✔
189
    options: list[EnumOption] | None = None
4✔
190
    grouped_options: list[EnumOptionGroup] | None = None
4✔
191

192

193
@frozen(kw_only=True)
4✔
194
class LinkSetting(AbstractSetting):
4✔
195
    """A LINK setting."""
196

197
    type: ConfigSettingType = ConfigSettingType.LINK
4✔
198
    url: str
3✔
199
    image: str
3✔
200

201

202
@frozen(kw_only=True)
4✔
203
class PageSetting(AbstractSetting):
4✔
204
    """A PAGE setting."""
205

206
    type: ConfigSettingType = ConfigSettingType.PAGE
4✔
207
    page: str
3✔
208
    image: str
3✔
209

210

211
@frozen(kw_only=True)
4✔
212
class ImageSetting(AbstractSetting):
4✔
213
    """An IMAGE setting."""
214

215
    type: ConfigSettingType = ConfigSettingType.IMAGE
4✔
216
    image: str
3✔
217

218

219
@frozen(kw_only=True)
4✔
220
class IconSetting(AbstractSetting):
4✔
221
    """An ICON setting."""
222

223
    type: ConfigSettingType = ConfigSettingType.ICON
4✔
224
    image: str
3✔
225

226

227
@frozen(kw_only=True)
4✔
228
class TimeSetting(AbstractSetting):
4✔
229
    """A TIME setting."""
230

231
    type: ConfigSettingType = ConfigSettingType.TIME
4✔
232

233

234
@frozen(kw_only=True)
4✔
235
class ParagraphSetting(AbstractSetting):
4✔
236
    """A PARAGRAPH setting."""
237

238
    type: ConfigSettingType = ConfigSettingType.PARAGRAPH
4✔
239
    default_value: str
3✔
240

241

242
@frozen(kw_only=True)
4✔
243
class EmailSetting(AbstractSetting):
4✔
244
    """An EMAIL setting."""
245

246
    type: ConfigSettingType = ConfigSettingType.EMAIL
4✔
247

248

249
@frozen(kw_only=True)
4✔
250
class DecimalSetting(AbstractSetting):
4✔
251
    """A DECIMAL setting."""
252

253
    type: ConfigSettingType = ConfigSettingType.DECIMAL
4✔
254

255

256
@frozen(kw_only=True)
4✔
257
class NumberSetting(AbstractSetting):
4✔
258
    """A NUMBER setting."""
259

260
    type: ConfigSettingType = ConfigSettingType.NUMBER
4✔
261

262

263
@frozen(kw_only=True)
4✔
264
class PhoneSetting(AbstractSetting):
4✔
265
    """A PHONE setting."""
266

267
    type: ConfigSettingType = ConfigSettingType.PHONE
4✔
268

269

270
@frozen(kw_only=True)
4✔
271
class OauthSetting(AbstractSetting):
4✔
272
    """An OAUTH setting."""
273

274
    type: ConfigSettingType = ConfigSettingType.OAUTH
4✔
275
    browser: bool
3✔
276
    url_template: str
3✔
277

278

279
ConfigSetting = (
4✔
280
    DeviceSetting
281
    | TextSetting
282
    | BooleanSetting
283
    | EnumSetting
284
    | LinkSetting
285
    | PageSetting
286
    | ImageSetting
287
    | IconSetting
288
    | TimeSetting
289
    | ParagraphSetting
290
    | EmailSetting
291
    | DecimalSetting
292
    | NumberSetting
293
    | PhoneSetting
294
    | OauthSetting
295
)
296

297

298
@frozen(kw_only=True)
4✔
299
class DeviceValue:
4✔
300
    device_id: str
3✔
301
    component_id: str
3✔
302

303

304
@frozen(kw_only=True)
4✔
305
class DeviceConfigValue:
4✔
306
    """DEVICE configuration value."""
307

308
    device_config: DeviceValue
3✔
309
    value_type: ConfigValueType = ConfigValueType.DEVICE
4✔
310

311

312
@frozen(kw_only=True)
4✔
313
class StringValue:
4✔
314
    value: str
3✔
315

316

317
@frozen(kw_only=True)
4✔
318
class StringConfigValue:
4✔
319
    """STRING configuration value."""
320

321
    string_config: StringValue
3✔
322
    value_type: ConfigValueType = ConfigValueType.STRING
4✔
323

324

325
ConfigValue = DeviceConfigValue | StringConfigValue
4✔
326

327

328
@frozen(kw_only=True)
4✔
329
class InstalledApp:
4✔
330
    """Installed application."""
331

332
    installed_app_id: str
3✔
333
    location_id: str
3✔
334
    config: dict[str, list[ConfigValue]]
3✔
335
    permissions: list[str] = field(factory=list)
4✔
336

337
    def as_devices(self, key: str) -> list[DeviceValue]:
4✔
338
        """Return a list of devices for a named configuration value."""
339
        return [item.device_config for item in self.config[key]]  # type: ignore[union-attr]
4✔
340

341
    def as_str(self, key: str) -> str:
4✔
342
        """Return a named configuration value, interpreted as a string"""
343
        return self.config[key][0].string_config.value  # type: ignore[union-attr]
4✔
344

345
    def as_bool(self, key: str) -> bool:
4✔
346
        """Return a named configuration value, interpreted as a boolean"""
347
        return bool(self.as_str(key))
4✔
348

349
    def as_int(self, key: str) -> int:
4✔
350
        """Return a named configuration value, interpreted as an integer"""
351
        return int(self.as_str(key))
4✔
352

353
    def as_float(self, key: str) -> float:
4✔
354
        """Return a named configuration value, interpreted as a float"""
355
        return float(self.as_str(key))
4✔
356

357

358
@frozen(kw_only=True)
4✔
359
class Event:
4✔
360
    """Holds the triggered event, one of several different attributes depending on event type."""
361

362
    event_time: Arrow | None = None
4✔
363
    event_type: EventType
3✔
364
    device_event: dict[str, Any] | None = None
4✔
365
    device_lifecycle_event: dict[str, Any] | None = None
4✔
366
    device_health_event: dict[str, Any] | None = None
4✔
367
    device_commands_event: dict[str, Any] | None = None
4✔
368
    mode_event: dict[str, Any] | None = None
4✔
369
    timer_event: dict[str, Any] | None = None
4✔
370
    scene_lifecycle_event: dict[str, Any] | None = None
4✔
371
    security_arm_state_event: dict[str, Any] | None = None
4✔
372
    hub_health_event: dict[str, Any] | None = None
4✔
373
    installed_app_lifecycle_event: dict[str, Any] | None = None
4✔
374
    weather_event: dict[str, Any] | None = None
4✔
375
    weather_data: dict[str, Any] | None = None
4✔
376
    air_quality_data: dict[str, Any] | None = None
4✔
377

378
    # noinspection PyUnreachableCode
379
    def for_type(self, event_type: EventType) -> dict[str, Any] | None:  # noqa: PLR0911
4✔
380
        """Return the attribute associated with an event type."""
381
        if event_type == EventType.DEVICE_COMMANDS_EVENT:
4✔
382
            return self.device_commands_event
4✔
383
        if event_type == EventType.DEVICE_EVENT:
4✔
384
            return self.device_event
4✔
385
        if event_type == EventType.DEVICE_HEALTH_EVENT:
4✔
386
            return self.device_health_event
4✔
387
        if event_type == EventType.DEVICE_LIFECYCLE_EVENT:
4✔
388
            return self.device_lifecycle_event
4✔
389
        if event_type == EventType.HUB_HEALTH_EVENT:
4✔
390
            return self.hub_health_event
4✔
391
        if event_type == EventType.INSTALLED_APP_LIFECYCLE_EVENT:
4✔
392
            return self.installed_app_lifecycle_event
4✔
393
        if event_type == EventType.MODE_EVENT:
4✔
394
            return self.mode_event
4✔
395
        if event_type == EventType.SCENE_LIFECYCLE_EVENT:
4✔
396
            return self.scene_lifecycle_event
4✔
397
        if event_type == EventType.SECURITY_ARM_STATE_EVENT:
4✔
398
            return self.security_arm_state_event
4✔
399
        if event_type == EventType.TIMER_EVENT:
4✔
400
            return self.timer_event
4✔
401
        if event_type == EventType.WEATHER_EVENT:
4✔
402
            return self.weather_event
4✔
403
        assert_never(event_type)
×
404

405

406
@frozen(kw_only=True)
4✔
407
class ConfirmationData:
4✔
408
    """Confirmation data."""
409

410
    app_id: str
3✔
411
    confirmation_url: str
3✔
412

413

414
@frozen(kw_only=True)
4✔
415
class ConfigInit:
4✔
416
    """Initialization data."""
417

418
    id: str
3✔
419
    name: str
3✔
420
    description: str
3✔
421
    permissions: list[str]
3✔
422
    first_page_id: str
3✔
423

424

425
@frozen(kw_only=True)
4✔
426
class ConfigRequestData:
4✔
427
    """Configuration data provided on the request."""
428

429
    installed_app_id: str
3✔
430
    phase: ConfigPhase
3✔
431
    page_id: str
3✔
432
    previous_page_id: str
3✔
433
    config: dict[str, list[ConfigValue]]
3✔
434

435

436
@frozen(kw_only=True)
4✔
437
class ConfigInitData:
4✔
438
    """Configuration data provided in an INITIALIZATION response."""
439

440
    initialize: ConfigInit
3✔
441

442

443
@frozen(kw_only=True)
4✔
444
class ConfigSection:
4✔
445
    """A section within a configuration page."""
446

447
    name: str
3✔
448
    settings: list[ConfigSetting]
3✔
449

450

451
@frozen(kw_only=True)
4✔
452
class ConfigPage:
4✔
453
    """A page of configuration data for the CONFIGURATION phase."""
454

455
    page_id: str
3✔
456
    name: str
3✔
457
    previous_page_id: str | None
3✔
458
    next_page_id: str | None
3✔
459
    complete: bool
3✔
460
    sections: list[ConfigSection]
3✔
461

462

463
@frozen(kw_only=True)
4✔
464
class ConfigPageData:
4✔
465
    """Configuration data provided in an PAGE response."""
466

467
    page: ConfigPage
3✔
468

469

470
@frozen(kw_only=True)
4✔
471
class InstallData:
4✔
472
    """Install data."""
473

474
    # note: auth_token and refresh_token are secrets, so we don't include them in string output
475

476
    auth_token: str = field(repr=False)
4✔
477
    refresh_token: str = field(repr=False)
4✔
478
    installed_app: InstalledApp
3✔
479

480
    def token(self) -> str:
4✔
481
        """Return the auth token associated with this request."""
482
        return self.auth_token
4✔
483

484
    def app_id(self) -> str:
4✔
485
        """Return the installed application id associated with this request."""
486
        return self.installed_app.installed_app_id
4✔
487

488
    def location_id(self) -> str:
4✔
489
        """Return the installed location id associated with this request."""
490
        return self.installed_app.location_id
4✔
491

492
    def as_devices(self, key: str) -> list[DeviceValue]:
4✔
493
        """Return a list of devices for a named configuration value."""
494
        return self.installed_app.as_devices(key)
4✔
495

496
    def as_str(self, key: str) -> str:
4✔
497
        """Return a named configuration value, interpreted as a string"""
498
        return self.installed_app.as_str(key)
4✔
499

500
    def as_bool(self, key: str) -> bool:
4✔
501
        """Return a named configuration value, interpreted as a boolean"""
502
        return self.installed_app.as_bool(key)
4✔
503

504
    def as_int(self, key: str) -> int:
4✔
505
        """Return a named configuration value, interpreted as an integer"""
506
        return self.installed_app.as_int(key)
4✔
507

508
    def as_float(self, key: str) -> float:
4✔
509
        """Return a named configuration value, interpreted as a float"""
510
        return self.installed_app.as_float(key)
4✔
511

512

513
@frozen(kw_only=True)
4✔
514
class UpdateData:
4✔
515
    """Update data."""
516

517
    # note: auth_token and refresh_token are secrets, so we don't include them in string output
518

519
    auth_token: str = field(repr=False)
4✔
520
    refresh_token: str = field(repr=False)
4✔
521
    installed_app: InstalledApp
3✔
522
    previous_config: dict[str, list[ConfigValue]] | None = None
4✔
523
    previous_permissions: list[str] = field(factory=list)
4✔
524

525
    def token(self) -> str:
4✔
526
        """Return the auth token associated with this request."""
527
        return self.auth_token
4✔
528

529
    def app_id(self) -> str:
4✔
530
        """Return the installed application id associated with this request."""
531
        return self.installed_app.installed_app_id
4✔
532

533
    def location_id(self) -> str:
4✔
534
        """Return the installed location id associated with this request."""
535
        return self.installed_app.location_id
4✔
536

537
    def as_devices(self, key: str) -> list[DeviceValue]:
4✔
538
        """Return a list of devices for a named configuration value."""
539
        return self.installed_app.as_devices(key)
4✔
540

541
    def as_str(self, key: str) -> str:
4✔
542
        """Return a named configuration value, interpreted as a string"""
543
        return self.installed_app.as_str(key)
4✔
544

545
    def as_bool(self, key: str) -> bool:
4✔
546
        """Return a named configuration value, interpreted as a boolean"""
547
        return self.installed_app.as_bool(key)
4✔
548

549
    def as_int(self, key: str) -> int:
4✔
550
        """Return a named configuration value, interpreted as an integer"""
551
        return self.installed_app.as_int(key)
4✔
552

553
    def as_float(self, key: str) -> float:
4✔
554
        """Return a named configuration value, interpreted as a float"""
555
        return self.installed_app.as_float(key)
4✔
556

557

558
@frozen(kw_only=True)
4✔
559
class UninstallData:
4✔
560
    """Install data."""
561

562
    installed_app: InstalledApp
3✔
563

564
    def app_id(self) -> str:
4✔
565
        """Return the installed application id associated with this request."""
566
        return self.installed_app.installed_app_id
4✔
567

568
    def location_id(self) -> str:
4✔
569
        """Return the installed location id associated with this request."""
570
        return self.installed_app.location_id
4✔
571

572

573
@frozen(kw_only=True)
4✔
574
class OauthCallbackData:
4✔
575
    installed_app_id: str
3✔
576
    url_path: str
3✔
577

578

579
@frozen(kw_only=True)
4✔
580
class EventData:
4✔
581
    """Event data."""
582

583
    # note: auth_token is a secret, so we don't include it in string output
584

585
    auth_token: str = field(repr=False)
4✔
586
    installed_app: InstalledApp
3✔
587
    events: list[Event]
3✔
588

589
    def token(self) -> str:
4✔
590
        """Return the auth token associated with this request."""
591
        return self.auth_token
4✔
592

593
    def app_id(self) -> str:
4✔
594
        """Return the installed application id associated with this request."""
595
        return self.installed_app.installed_app_id
4✔
596

597
    def location_id(self) -> str:
4✔
598
        """Return the installed location id associated with this request."""
599
        return self.installed_app.location_id
4✔
600

601
    def for_type(self, event_type: EventType) -> list[dict[str, Any]]:
4✔
602
        """Get all events for a particular event type, possibly empty."""
603
        return [
4✔
604
            event.for_type(event_type)  # type: ignore[misc]
605
            for event in self.events
606
            if event.event_type == event_type and event.for_type(event_type) is not None
607
        ]
608

609
    def filter(self, event_type: EventType, predicate: Callable[[dict[str, Any]], bool] | None = None) -> list[dict[str, Any]]:
4✔
610
        """Apply a filter to a set of events with a particular event type."""
611
        return list(filter(predicate, self.for_type(event_type)))
4✔
612

613

614
@frozen(kw_only=True)
4✔
615
class ConfirmationRequest(AbstractRequest):
4✔
616
    """Request for CONFIRMATION phase"""
617

618
    app_id: str
3✔
619
    confirmation_data: ConfirmationData
3✔
620
    settings: dict[str, Any] = field(factory=dict)
4✔
621

622

623
@frozen(kw_only=True)
4✔
624
class ConfirmationResponse:
4✔
625
    """Response for CONFIRMATION phase"""
626

627
    target_url: str
3✔
628

629

630
@frozen(kw_only=True)
4✔
631
class ConfigurationRequest(AbstractRequest):
4✔
632
    """Request for CONFIGURATION phase"""
633

634
    configuration_data: ConfigRequestData
3✔
635
    settings: dict[str, Any] = field(factory=dict)
4✔
636

637

638
@frozen(kw_only=True)
4✔
639
class ConfigurationInitResponse:
4✔
640
    """Response for CONFIGURATION/INITIALIZE phase"""
641

642
    configuration_data: ConfigInitData
3✔
643

644

645
@frozen(kw_only=True)
4✔
646
class ConfigurationPageResponse:
4✔
647
    """Response for CONFIGURATION/PAGE phase"""
648

649
    configuration_data: ConfigPageData
3✔
650

651

652
@frozen(kw_only=True)
4✔
653
class InstallRequest(AbstractRequest):
4✔
654
    """Request for INSTALL phase"""
655

656
    install_data: InstallData
3✔
657
    settings: dict[str, Any] = field(factory=dict)
4✔
658

659
    def token(self) -> str:
4✔
660
        """Return the auth token associated with this request."""
661
        return self.install_data.token()
4✔
662

663
    def app_id(self) -> str:
4✔
664
        """Return the installed application id associated with this request."""
665
        return self.install_data.app_id()
4✔
666

667
    def location_id(self) -> str:
4✔
668
        """Return the installed location id associated with this request."""
669
        return self.install_data.location_id()
4✔
670

671
    def as_devices(self, key: str) -> list[DeviceValue]:
4✔
672
        """Return a list of devices for a named configuration value."""
673
        return self.install_data.as_devices(key)
4✔
674

675
    def as_str(self, key: str) -> str:
4✔
676
        """Return a named configuration value, interpreted as a string"""
677
        return self.install_data.as_str(key)
4✔
678

679
    def as_bool(self, key: str) -> bool:
4✔
680
        """Return a named configuration value, interpreted as a boolean"""
681
        return self.install_data.as_bool(key)
4✔
682

683
    def as_int(self, key: str) -> int:
4✔
684
        """Return a named configuration value, interpreted as an integer"""
685
        return self.install_data.as_int(key)
4✔
686

687
    def as_float(self, key: str) -> float:
4✔
688
        """Return a named configuration value, interpreted as a float"""
689
        return self.install_data.as_float(key)
4✔
690

691

692
@frozen(kw_only=True)
4✔
693
class InstallResponse:
4✔
694
    """Response for INSTALL phase"""
695

696
    install_data: dict[str, Any] = field(factory=dict)  # always empty in the response
4✔
697

698

699
@frozen(kw_only=True)
4✔
700
class UpdateRequest(AbstractRequest):
4✔
701
    """Request for UPDATE phase"""
702

703
    update_data: UpdateData
3✔
704
    settings: dict[str, Any] = field(factory=dict)
4✔
705

706
    def token(self) -> str:
4✔
707
        """Return the auth token associated with this request."""
708
        return self.update_data.token()
4✔
709

710
    def app_id(self) -> str:
4✔
711
        """Return the installed application id associated with this request."""
712
        return self.update_data.app_id()
4✔
713

714
    def location_id(self) -> str:
4✔
715
        """Return the installed location id associated with this request."""
716
        return self.update_data.location_id()
4✔
717

718
    def as_devices(self, key: str) -> list[DeviceValue]:
4✔
719
        """Return a list of devices for a named configuration value."""
720
        return self.update_data.as_devices(key)
4✔
721

722
    def as_str(self, key: str) -> str:
4✔
723
        """Return a named configuration value, interpreted as a string"""
724
        return self.update_data.as_str(key)
4✔
725

726
    def as_bool(self, key: str) -> bool:
4✔
727
        """Return a named configuration value, interpreted as a boolean"""
728
        return self.update_data.as_bool(key)
4✔
729

730
    def as_int(self, key: str) -> int:
4✔
731
        """Return a named configuration value, interpreted as an integer"""
732
        return self.update_data.as_int(key)
4✔
733

734
    def as_float(self, key: str) -> float:
4✔
735
        """Return a named configuration value, interpreted as a float"""
736
        return self.update_data.as_float(key)
4✔
737

738

739
@frozen(kw_only=True)
4✔
740
class UpdateResponse:
4✔
741
    """Response for UPDATE phase"""
742

743
    update_data: dict[str, Any] = field(factory=dict)  # always empty in the response
4✔
744

745

746
@frozen(kw_only=True)
4✔
747
class UninstallRequest(AbstractRequest):
4✔
748
    """Request for UNINSTALL phase"""
749

750
    uninstall_data: UninstallData
3✔
751
    settings: dict[str, Any] = field(factory=dict)
4✔
752

753
    def app_id(self) -> str:
4✔
754
        """Return the installed application id associated with this request."""
755
        return self.uninstall_data.app_id()
4✔
756

757
    def location_id(self) -> str:
4✔
758
        """Return the installed location id associated with this request."""
759
        return self.uninstall_data.location_id()
4✔
760

761

762
@frozen(kw_only=True)
4✔
763
class UninstallResponse:
4✔
764
    """Response for UNINSTALL phase"""
765

766
    uninstall_data: dict[str, Any] = field(factory=dict)  # always empty in the response
4✔
767

768

769
@frozen(kw_only=True)
4✔
770
class OauthCallbackRequest(AbstractRequest):
4✔
771
    """Request for OAUTH_CALLBACK phase"""
772

773
    o_auth_callback_data: OauthCallbackData
3✔
774

775

776
@frozen(kw_only=True)
4✔
777
class OauthCallbackResponse:
4✔
778
    """Response for OAUTH_CALLBACK phase"""
779

780
    o_auth_callback_data: dict[str, Any] = field(factory=dict)  # always empty in the response
4✔
781

782

783
@frozen(kw_only=True)
4✔
784
class EventRequest(AbstractRequest):
4✔
785
    """Request for EVENT phase"""
786

787
    event_data: EventData
3✔
788
    settings: dict[str, Any] = field(factory=dict)
4✔
789

790
    def token(self) -> str:
4✔
791
        """Return the auth token associated with this request."""
792
        return self.event_data.token()
4✔
793

794
    def app_id(self) -> str:
4✔
795
        """Return the installed application id associated with this request."""
796
        return self.event_data.app_id()
4✔
797

798
    def location_id(self) -> str:
4✔
799
        """Return the installed location id associated with this request."""
800
        return self.event_data.location_id()
4✔
801

802

803
@frozen(kw_only=True)
4✔
804
class EventResponse:
4✔
805
    """Response for EVENT phase"""
806

807
    event_data: dict[str, Any] = field(factory=dict)  # always empty in the response
4✔
808

809

810
LifecycleRequest = (
4✔
811
    ConfigurationRequest
812
    | ConfirmationRequest
813
    | InstallRequest
814
    | UpdateRequest
815
    | UninstallRequest
816
    | OauthCallbackRequest
817
    | EventRequest
818
)
819

820

821
LifecycleResponse = (
4✔
822
    ConfigurationInitResponse
823
    | ConfigurationPageResponse
824
    | ConfirmationResponse
825
    | InstallResponse
826
    | UpdateResponse
827
    | UninstallResponse
828
    | OauthCallbackResponse
829
    | EventResponse
830
)
831

832

833
REQUEST_BY_PHASE = {
4✔
834
    LifecyclePhase.CONFIGURATION: ConfigurationRequest,
835
    LifecyclePhase.CONFIRMATION: ConfirmationRequest,
836
    LifecyclePhase.INSTALL: InstallRequest,
837
    LifecyclePhase.UPDATE: UpdateRequest,
838
    LifecyclePhase.UNINSTALL: UninstallRequest,
839
    LifecyclePhase.OAUTH_CALLBACK: OauthCallbackRequest,
840
    LifecyclePhase.EVENT: EventRequest,
841
}
842

843
CONFIG_VALUE_BY_TYPE = {
4✔
844
    ConfigValueType.DEVICE: DeviceConfigValue,
845
    ConfigValueType.STRING: StringConfigValue,
846
}
847

848
CONFIG_SETTING_BY_TYPE = {
4✔
849
    ConfigSettingType.DEVICE: DeviceSetting,
850
    ConfigSettingType.TEXT: TextSetting,
851
    ConfigSettingType.BOOLEAN: BooleanSetting,
852
    ConfigSettingType.ENUM: EnumSetting,
853
    ConfigSettingType.LINK: LinkSetting,
854
    ConfigSettingType.PAGE: PageSetting,
855
    ConfigSettingType.IMAGE: ImageSetting,
856
    ConfigSettingType.ICON: IconSetting,
857
    ConfigSettingType.TIME: TimeSetting,
858
    ConfigSettingType.PARAGRAPH: ParagraphSetting,
859
    ConfigSettingType.EMAIL: EmailSetting,
860
    ConfigSettingType.DECIMAL: DecimalSetting,
861
    ConfigSettingType.NUMBER: NumberSetting,
862
    ConfigSettingType.PHONE: PhoneSetting,
863
    ConfigSettingType.OAUTH: OauthSetting,
864
}
865

866

867
@frozen
4✔
868
class SmartAppError(Exception):
4✔
869
    """An error tied to the SmartApp implementation."""
870

871
    message: str
3✔
872
    correlation_id: str | None = None
4✔
873

874

875
@frozen
4✔
876
class InternalError(SmartAppError):
4✔
877
    """An internal error was encountered processing a lifecycle event."""
878

879

880
@frozen
4✔
881
class BadRequestError(SmartAppError):
4✔
882
    """A lifecycle event was invalid."""
883

884

885
@frozen
4✔
886
class SignatureError(SmartAppError):
4✔
887
    """The request signature on a lifecycle event was invalid."""
888

889

890
@frozen(kw_only=True)
4✔
891
class SmartAppDispatcherConfig:
4✔
892
    # noinspection PyUnresolvedReferences
893
    """
894
    Configuration for the SmartAppDispatcher.
895

896
    Any production SmartApp should always check signatures.  We support disabling that feature
897
    to make local testing easier during development.
898

899
    BEWARE: setting `log_json` to `True` will potentially place secrets (such as authorization
900
    keys) in your logs.  This is intended for use during development and debugging only.
901

902
    Attributes:
903
        check_signatures(bool): Whether to check the digital signature on lifecycle requests
904
        clock_skew_sec(int): Amount of clock skew allowed when verifying digital signatures, or None to allow any skew
905
        keyserver_url(str): The SmartThings keyserver URL, where we retrieve keys for signature checks
906
        log_json(bool): Whether to log JSON data at DEBUG level when processing requests
907
    """
908

909
    check_signatures: bool = True
4✔
910
    clock_skew_sec: int | None = 300
4✔
911
    keyserver_url: str = "https://key.smartthings.com"
4✔
912
    log_json: bool = False
4✔
913

914

915
class SmartAppEventHandler(ABC):
4✔
916
    """
917
    Application event handler for SmartApp lifecycle events.
918

919
    Inherit from this class to implement your own application-specific event handler.
920
    The application-specific event handler is always called first, before any default
921
    event handler logic in the dispatcher itself.
922

923
    The correlation id is an optional value that you can associate with your log messages.
924
    It may aid in debugging if you need to contact SmartThings for support.
925

926
    Some lifecycle events do not require you to implement any custom event handler logic:
927

928
    - CONFIRMATION: normally no callback needed, since the dispatcher logs the app id and confirmation URL
929
    - CONFIGURATION: normally no callback needed, since the dispatcher has the information it needs to respond
930
    - INSTALL/UPDATE: set up or replace subscriptions and schedules and persist required data, if any
931
    - UNINSTALL: remove persisted data, if any
932
    - OAUTH_CALLBACK: coordinate with your oauth provider as needed
933
    - EVENT: handle SmartThings events or scheduled triggers
934

935
    The EventRequest object that you receive for the EVENT callback includes an
936
    authorization token and also the entire configuration bundle for the installed
937
    application.  So, if your SmartApp is built around event handling and scheduled
938
    actions triggered by SmartThings, your handler can probably be stateless.  There is
939
    probably is not any need to persist any of the data returned in the INSTALL or UPDATE
940
    lifecycle events into your own data store.
941

942
    Note that SmartAppHandler is a synchronous and single-threaded interface.  The
943
    assumption is that if you need high-volume asynchronous or multi-threaded processing,
944
    you will implement that at the tier above this where the actual POST requests are
945
    accepted from remote callers.
946
    """
947

948
    @abstractmethod
4✔
949
    def handle_confirmation(self, correlation_id: str | None, request: ConfirmationRequest) -> None:
4✔
950
        """Handle a CONFIRMATION lifecycle request"""
951

952
    @abstractmethod
4✔
953
    def handle_configuration(self, correlation_id: str | None, request: ConfigurationRequest) -> None:
4✔
954
        """Handle a CONFIGURATION lifecycle request."""
955

956
    @abstractmethod
4✔
957
    def handle_install(self, correlation_id: str | None, request: InstallRequest) -> None:
4✔
958
        """Handle an INSTALL lifecycle request."""
959

960
    @abstractmethod
4✔
961
    def handle_update(self, correlation_id: str | None, request: UpdateRequest) -> None:
4✔
962
        """Handle an UPDATE lifecycle request."""
963

964
    @abstractmethod
4✔
965
    def handle_uninstall(self, correlation_id: str | None, request: UninstallRequest) -> None:
4✔
966
        """Handle an UNINSTALL lifecycle request."""
967

968
    @abstractmethod
4✔
969
    def handle_oauth_callback(self, correlation_id: str | None, request: OauthCallbackRequest) -> None:
4✔
970
        """Handle an OAUTH_CALLBACK lifecycle request."""
971

972
    @abstractmethod
4✔
973
    def handle_event(self, correlation_id: str | None, request: EventRequest) -> None:
4✔
974
        """Handle an EVENT lifecycle request."""
975

976

977
@frozen(kw_only=True)
4✔
978
class SmartAppConfigPage:
4✔
979
    """
980
    A page of configuration for the SmartApp.
981
    """
982

983
    page_name: str
3✔
984
    sections: list[ConfigSection]
3✔
985

986

987
@frozen(kw_only=True)
4✔
988
class SmartAppDefinition:
4✔
989
    # noinspection PyUnresolvedReferences
990
    """
991
    The definition of the SmartApp.
992

993
    All of this data would normally be static for any given version of your application.
994
    If you wish, you can maintain the definition in YAML or JSON in your source tree
995
    and parse it with `smartapp.converter.CONVERTER`.
996

997
    Keep in mind that the JSON or YAML format on disk will be consistent with the SmartThings
998
    lifecycle API, so it will use camel case attribute names (like `configPages`) rather than
999
    the Python attribute names you see in source code (like `config_pages`).
1000

1001
    Attributes:
1002
        id(str): Identifier for this SmartApp
1003
        name(str): Name of the SmartApp
1004
        description(str): Description of the SmartApp
1005
        permissions(List[str]): Permissions that the SmartApp requires
1006
        config_pages(List[SmartAppConfigPage]): Configuration pages that the SmartApp will offer users
1007
    """
1008

1009
    id: str
3✔
1010
    name: str
3✔
1011
    description: str
3✔
1012
    target_url: str
3✔
1013
    permissions: list[str]
3✔
1014
    config_pages: list[SmartAppConfigPage] | None
3✔
1015

1016

1017
# noinspection PyShadowingBuiltins,PyMethodMayBeStatic
1018
class SmartAppConfigManager(ABC):
4✔
1019
    """
1020
    Configuration manager, used by the dispatcher to respond to CONFIGURATION events.
1021

1022
    The dispatcher has a default configuration manager.  However, you can implement your
1023
    own if that default behavior does not meet your needs.  For instance, a static config
1024
    definition is adequate for lots of SmartApps, but it doesn't work for some types of
1025
    complex configuration, where the responses need to be generated dynamically.  In that
1026
    case, you can implement your own configuration manager with that specialized behavior.
1027

1028
    This abstract class also includes several convenience methods to make it easier to
1029
    build responses.
1030
    """
1031

1032
    def handle_initialize(self, _request: ConfigurationRequest, definition: SmartAppDefinition) -> ConfigurationInitResponse:
4✔
1033
        """Handle a CONFIGURATION INITIALIZE lifecycle request."""
1034
        return self.build_init_response(
4✔
1035
            id=definition.id,
1036
            name=definition.name,
1037
            description=definition.description,
1038
            permissions=definition.permissions,
1039
            first_page_id=1,
1040
        )
1041

1042
    @abstractmethod
4✔
1043
    def handle_page(self, request: ConfigurationRequest, definition: SmartAppDefinition, page_id: int) -> ConfigurationPageResponse:
4✔
1044
        """Handle a CONFIGURATION PAGE lifecycle request."""
1045

1046
    def build_init_response(  # noqa: PLR6301
4✔
1047
        self,
1048
        id: str,  # noqa: A002
1049
        name: str,
1050
        description: str,
1051
        permissions: list[str],
1052
        first_page_id: int,
1053
    ) -> ConfigurationInitResponse:
1054
        """Build a ConfigurationInitResponse."""
1055
        return ConfigurationInitResponse(
4✔
1056
            configuration_data=ConfigInitData(
1057
                initialize=ConfigInit(
1058
                    id=id,
1059
                    name=name,
1060
                    description=description,
1061
                    permissions=permissions,
1062
                    first_page_id=str(first_page_id),
1063
                )
1064
            )
1065
        )
1066

1067
    def build_page_response(  # noqa: PLR0913,PLR0917,PLR6301
4✔
1068
        self,
1069
        page_id: int,
1070
        name: str,
1071
        previous_page_id: int | None,
1072
        next_page_id: int | None,
1073
        complete: bool,  # noqa: FBT001
1074
        sections: list[ConfigSection],
1075
    ) -> ConfigurationPageResponse:
1076
        """Build a ConfigurationPageResponse."""
1077
        return ConfigurationPageResponse(
4✔
1078
            configuration_data=ConfigPageData(
1079
                page=ConfigPage(
1080
                    name=name,
1081
                    page_id=str(page_id),
1082
                    previous_page_id=str(previous_page_id) if previous_page_id else None,
1083
                    next_page_id=str(next_page_id) if next_page_id else None,
1084
                    complete=complete,
1085
                    sections=sections,
1086
                )
1087
            )
1088
        )
1089

1090

1091
# noinspection PyUnresolvedReferences
1092
@frozen(kw_only=True)
4✔
1093
class SmartAppRequestContext:
4✔
1094
    """
1095
    The context for a SmartApp lifecycle request.
1096

1097
    Attributes:
1098
        headers(Mapping[str, str]): The request headers
1099
        body(str): The body of the request as string
1100
    """
1101

1102
    # I'm pulling out the correlation id, signature, and date because they are 3 specific
1103
    # headers that I know the SmartThings API always provides.  Others can be pulled out
1104
    # using header().
1105

1106
    headers: Mapping[str, str] = field(factory=dict)
4✔
1107
    body: str = ""
4✔
1108
    normalized: Mapping[str, str] = field(init=False)
4✔
1109
    correlation_id: str = field(init=False)
4✔
1110
    signature: str = field(init=False)
4✔
1111
    date: str = field(init=False)
4✔
1112

1113
    @normalized.default
4✔
1114
    def _default_normalized(self) -> Mapping[str, str]:
4✔
1115
        # in conjunction with header(), this gives us a case-insensitive dictionary
1116
        return {key.lower(): value for (key, value) in self.headers.items()} if self.headers else {}
4✔
1117

1118
    @correlation_id.default
4✔
1119
    def _default_correlation_id(self) -> str | None:
4✔
1120
        return self.header(CORRELATION_ID_HEADER)
4✔
1121

1122
    @signature.default
4✔
1123
    def _default_signature(self) -> str | None:
4✔
1124
        return self.header(AUTHORIZATION_HEADER)
4✔
1125

1126
    @date.default
4✔
1127
    def _default_date(self) -> str | None:
4✔
1128
        return self.header(DATE_HEADER)
4✔
1129

1130
    def header(self, name: str) -> str | None:
4✔
1131
        """Return the named header case-insensitively, or None if not found."""
1132
        if name.lower() not in self.normalized:
4✔
1133
            return None
4✔
1134
        value = self.normalized[name.lower()]
4✔
1135
        if not value or not value.strip():
4✔
1136
            return None
4✔
1137
        return value
4✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc