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

pronovic / smartapp-sdk / 17219203523

25 Aug 2025 07:53PM UTC coverage: 98.509% (+0.002%) from 98.507%
17219203523

Pull #31

github

web-flow
Merge 622f5a116 into 0ab9205c9
Pull Request #31: Ruff linter fixes

155 of 158 new or added lines in 4 files covered. (98.1%)

5 existing lines in 2 files now uncovered.

793 of 805 relevant lines covered (98.51%)

2.96 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-preview.smartthings.com/docs/connected-services/lifecycles/
10
#   https://developer-preview.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 have access to private documentation that shows all of the attributes.  However, that
21
# documentation doesn't always make it clear which attributes will always be included and
22
# which are optional.  As compromise, I have decided to maintain the actual events as
23
# dicts rather than true objects.  See further discussion below by the Event class.
24

25
from abc import ABC, abstractmethod
3✔
26
from collections.abc import Callable, Mapping
3✔
27
from enum import Enum
3✔
28
from typing import Any
3✔
29

30
from arrow import Arrow
3✔
31
from attrs import field, frozen
3✔
32
from typing_extensions import assert_never
3✔
33

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

38

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

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

50

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

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

57

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

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

64

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

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

84

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

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

100

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

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

113

114
class BooleanValue(str, Enum):
3✔
115
    """String boolean values."""
116

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

120

121
@frozen(kw_only=True)
3✔
122
class AbstractRequest(ABC):
3✔
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)
3✔
132
class AbstractSetting(ABC):
3✔
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
3✔
139

140

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

145
    type: ConfigSettingType = ConfigSettingType.DEVICE
3✔
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)
3✔
152
class TextSetting(AbstractSetting):
3✔
153
    """A TEXT setting."""
154

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

158

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

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

166

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

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

174

175
@frozen(kw_only=True)
3✔
176
class EnumOptionGroup:
3✔
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)
3✔
184
class EnumSetting(AbstractSetting):
3✔
185
    """An ENUM setting."""
186

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

192

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

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

201

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

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

210

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

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

218

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

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

226

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

231
    type: ConfigSettingType = ConfigSettingType.TIME
3✔
232

233

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

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

241

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

246
    type: ConfigSettingType = ConfigSettingType.EMAIL
3✔
247

248

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

253
    type: ConfigSettingType = ConfigSettingType.DECIMAL
3✔
254

255

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

260
    type: ConfigSettingType = ConfigSettingType.NUMBER
3✔
261

262

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

267
    type: ConfigSettingType = ConfigSettingType.PHONE
3✔
268

269

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

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

278

279
ConfigSetting = (
3✔
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)
3✔
299
class DeviceValue:
3✔
300
    device_id: str
3✔
301
    component_id: str
3✔
302

303

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

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

311

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

316

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

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

324

325
ConfigValue = DeviceConfigValue | StringConfigValue
3✔
326

327

328
@frozen(kw_only=True)
3✔
329
class InstalledApp:
3✔
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)
3✔
336

337
    def as_devices(self, key: str) -> list[DeviceValue]:
3✔
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]
3✔
340

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

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

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

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

357

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

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

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

404

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

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

412

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

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

423

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

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

434

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

439
    initialize: ConfigInit
3✔
440

441

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

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

449

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

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

461

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

466
    page: ConfigPage
3✔
467

468

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

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

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

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

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

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

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

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

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

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

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

511

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

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

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

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

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

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

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

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

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

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

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

556

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

561
    installed_app: InstalledApp
3✔
562

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

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

571

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

577

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

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

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

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

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

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

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

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

612

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

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

621

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

626
    target_url: str
3✔
627

628

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

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

636

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

641
    configuration_data: ConfigInitData
3✔
642

643

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

648
    configuration_data: ConfigPageData
3✔
649

650

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

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

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

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

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

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

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

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

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

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

690

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

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

697

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

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

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

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

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

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

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

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

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

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

737

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

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

744

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

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

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

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

760

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

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

767

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

772
    o_auth_callback_data: OauthCallbackData
3✔
773

774

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

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

781

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

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

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

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

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

801

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

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

808

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

819

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

831

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

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

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

865

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

870
    message: str
3✔
871
    correlation_id: str | None = None
3✔
872

873

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

878

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

883

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

888

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

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

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

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

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

913

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

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

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

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

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

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

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

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

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

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

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

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

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

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

975

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

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

985

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

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

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

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

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

1015

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

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

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

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

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

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

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

1089

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

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

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

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

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

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

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

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

1129
    def header(self, name: str) -> str | None:
3✔
1130
        """Return the named header case-insensitively, or None if not found."""
1131
        if name.lower() not in self.normalized:
3✔
1132
            return None
3✔
1133
        value = self.normalized[name.lower()]
3✔
1134
        if not value or not value.strip():
3✔
1135
            return None
3✔
1136
        return value
3✔
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