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

pronovic / smartapp-sdk / 17206803797

25 Aug 2025 10:56AM UTC coverage: 98.507% (+0.001%) from 98.506%
17206803797

push

github

web-flow
Update the MyPy configuration so we're using latest rules (#28)

1 of 2 new or added lines in 1 file covered. (50.0%)

792 of 804 relevant lines covered (98.51%)

3.94 hits per line

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

99.8
/src/smartapp/interface.py
1
# -*- coding: utf-8 -*-
2
# vim: set ft=python ts=4 sw=4 expandtab:
3
# pylint: disable=line-too-long,too-many-lines,too-many-positional-arguments,too-many-instance-attributes:
4

5
"""
6
Classes that are part of the SmartApp interface.
7
"""
8

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

27
from abc import ABC, abstractmethod
4✔
28
from enum import Enum
4✔
29
from typing import Any, Callable, Dict, List, Mapping, Optional, Union
4✔
30

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

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

39

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

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

51

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

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

58

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

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

65

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

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

85

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

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

101

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

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

114

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

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

121

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

126
    lifecycle: LifecyclePhase
4✔
127
    execution_id: str
4✔
128
    locale: str
4✔
129
    version: str
4✔
130

131

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

136
    id: str
4✔
137
    name: str
4✔
138
    description: str
4✔
139
    required: Optional[bool] = False
4✔
140

141

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

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

151

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

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

159

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

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

167

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

172
    id: str
4✔
173
    name: str
4✔
174

175

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

180
    name: str
4✔
181
    options: List[EnumOption]
4✔
182

183

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

188
    type: ConfigSettingType = ConfigSettingType.ENUM
4✔
189
    multiple: bool
4✔
190
    options: Optional[List[EnumOption]] = None
4✔
191
    grouped_options: Optional[List[EnumOptionGroup]] = None
4✔
192

193

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

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

202

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

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

211

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

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

219

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

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

227

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

232
    type: ConfigSettingType = ConfigSettingType.TIME
4✔
233

234

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

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

242

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

247
    type: ConfigSettingType = ConfigSettingType.EMAIL
4✔
248

249

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

254
    type: ConfigSettingType = ConfigSettingType.DECIMAL
4✔
255

256

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

261
    type: ConfigSettingType = ConfigSettingType.NUMBER
4✔
262

263

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

268
    type: ConfigSettingType = ConfigSettingType.PHONE
4✔
269

270

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

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

279

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

298

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

304

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

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

312

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

317

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

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

325

326
ConfigValue = Union[
4✔
327
    DeviceConfigValue,
328
    StringConfigValue,
329
]
330

331

332
@frozen(kw_only=True)
4✔
333
class InstalledApp:
4✔
334
    """Installed application."""
335

336
    installed_app_id: str
4✔
337
    location_id: str
4✔
338
    config: Dict[str, List[ConfigValue]]
4✔
339
    permissions: List[str] = field(factory=list)
4✔
340

341
    def as_devices(self, key: str) -> List[DeviceValue]:
4✔
342
        """Return a list of devices for a named configuration value."""
343
        return [item.device_config for item in self.config[key]]  # type: ignore
4✔
344

345
    def as_str(self, key: str) -> str:
4✔
346
        """Return a named configuration value, interpreted as a string"""
347
        return self.config[key][0].string_config.value  # type: ignore
4✔
348

349
    def as_bool(self, key: str) -> bool:
4✔
350
        """Return a named configuration value, interpreted as a boolean"""
351
        return bool(self.as_str(key))
4✔
352

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

357
    def as_float(self, key: str) -> float:
4✔
358
        """Return a named configuration value, interpreted as a float"""
359
        return float(self.as_str(key))
4✔
360

361

362
@frozen(kw_only=True)
4✔
363
class Event:
4✔
364
    """Holds the triggered event, one of several different attributes depending on event type."""
365

366
    event_time: Optional[Arrow] = None
4✔
367
    event_type: EventType
4✔
368
    device_event: Optional[Dict[str, Any]] = None
4✔
369
    device_lifecycle_event: Optional[Dict[str, Any]] = None
4✔
370
    device_health_event: Optional[Dict[str, Any]] = None
4✔
371
    device_commands_event: Optional[Dict[str, Any]] = None
4✔
372
    mode_event: Optional[Dict[str, Any]] = None
4✔
373
    timer_event: Optional[Dict[str, Any]] = None
4✔
374
    scene_lifecycle_event: Optional[Dict[str, Any]] = None
4✔
375
    security_arm_state_event: Optional[Dict[str, Any]] = None
4✔
376
    hub_health_event: Optional[Dict[str, Any]] = None
4✔
377
    installed_app_lifecycle_event: Optional[Dict[str, Any]] = None
4✔
378
    weather_event: Optional[Dict[str, Any]] = None
4✔
379
    weather_data: Optional[Dict[str, Any]] = None
4✔
380
    air_quality_data: Optional[Dict[str, Any]] = None
4✔
381

382
    def for_type(self, event_type: EventType) -> Optional[Dict[str, Any]]:  # pylint: disable=too-many-return-statements
4✔
383
        """Return the attribute associated with an event type."""
384
        if event_type == EventType.DEVICE_COMMANDS_EVENT:
4✔
385
            return self.device_commands_event
4✔
386
        elif event_type == EventType.DEVICE_EVENT:
4✔
387
            return self.device_event
4✔
388
        elif event_type == EventType.DEVICE_HEALTH_EVENT:
4✔
389
            return self.device_health_event
4✔
390
        elif event_type == EventType.DEVICE_LIFECYCLE_EVENT:
4✔
391
            return self.device_lifecycle_event
4✔
392
        elif event_type == EventType.HUB_HEALTH_EVENT:
4✔
393
            return self.hub_health_event
4✔
394
        elif event_type == EventType.INSTALLED_APP_LIFECYCLE_EVENT:
4✔
395
            return self.installed_app_lifecycle_event
4✔
396
        elif event_type == EventType.MODE_EVENT:
4✔
397
            return self.mode_event
4✔
398
        elif event_type == EventType.SCENE_LIFECYCLE_EVENT:
4✔
399
            return self.scene_lifecycle_event
4✔
400
        elif event_type == EventType.SECURITY_ARM_STATE_EVENT:
4✔
401
            return self.security_arm_state_event
4✔
402
        elif event_type == EventType.TIMER_EVENT:
4✔
403
            return self.timer_event
4✔
404
        elif event_type == EventType.WEATHER_EVENT:
4✔
405
            return self.weather_event
4✔
NEW
406
        assert_never(event_type)
×
407

408

409
@frozen(kw_only=True)
4✔
410
class ConfirmationData:
4✔
411
    """Confirmation data."""
412

413
    app_id: str
4✔
414
    confirmation_url: str
4✔
415

416

417
@frozen(kw_only=True)
4✔
418
class ConfigInit:
4✔
419
    """Initialization data."""
420

421
    id: str
4✔
422
    name: str
4✔
423
    description: str
4✔
424
    permissions: List[str]
4✔
425
    first_page_id: str
4✔
426

427

428
@frozen(kw_only=True)
4✔
429
class ConfigRequestData:
4✔
430
    """Configuration data provided on the request."""
431

432
    installed_app_id: str
4✔
433
    phase: ConfigPhase
4✔
434
    page_id: str
4✔
435
    previous_page_id: str
4✔
436
    config: Dict[str, List[ConfigValue]]
4✔
437

438

439
@frozen(kw_only=True)
4✔
440
class ConfigInitData:
4✔
441
    """Configuration data provided in an INITIALIZATION response."""
442

443
    initialize: ConfigInit
4✔
444

445

446
@frozen(kw_only=True)
4✔
447
class ConfigSection:
4✔
448
    """A section within a configuration page."""
449

450
    name: str
4✔
451
    settings: List[ConfigSetting]
4✔
452

453

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

458
    page_id: str
4✔
459
    name: str
4✔
460
    previous_page_id: Optional[str]
4✔
461
    next_page_id: Optional[str]
4✔
462
    complete: bool
4✔
463
    sections: List[ConfigSection]
4✔
464

465

466
@frozen(kw_only=True)
4✔
467
class ConfigPageData:
4✔
468
    """Configuration data provided in an PAGE response."""
469

470
    page: ConfigPage
4✔
471

472

473
@frozen(kw_only=True)
4✔
474
class InstallData:
4✔
475
    """Install data."""
476

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

479
    auth_token: str = field(repr=False)
4✔
480
    refresh_token: str = field(repr=False)
4✔
481
    installed_app: InstalledApp
4✔
482

483
    def token(self) -> str:
4✔
484
        """Return the auth token associated with this request."""
485
        return self.auth_token
4✔
486

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

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

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

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

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

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

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

515

516
@frozen(kw_only=True)
4✔
517
class UpdateData:
4✔
518
    """Update data."""
519

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

522
    auth_token: str = field(repr=False)
4✔
523
    refresh_token: str = field(repr=False)
4✔
524
    installed_app: InstalledApp
4✔
525
    previous_config: Optional[Dict[str, List[ConfigValue]]] = None
4✔
526
    previous_permissions: List[str] = field(factory=list)
4✔
527

528
    def token(self) -> str:
4✔
529
        """Return the auth token associated with this request."""
530
        return self.auth_token
4✔
531

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

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

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

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

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

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

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

560

561
@frozen(kw_only=True)
4✔
562
class UninstallData:
4✔
563
    """Install data."""
564

565
    installed_app: InstalledApp
4✔
566

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

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

575

576
@frozen(kw_only=True)
4✔
577
class OauthCallbackData:
4✔
578
    installed_app_id: str
4✔
579
    url_path: str
4✔
580

581

582
@frozen(kw_only=True)
4✔
583
class EventData:
4✔
584
    """Event data."""
585

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

588
    auth_token: str = field(repr=False)
4✔
589
    installed_app: InstalledApp
4✔
590
    events: List[Event]
4✔
591

592
    def token(self) -> str:
4✔
593
        """Return the auth token associated with this request."""
594
        return self.auth_token
4✔
595

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

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

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

612
    def filter(self, event_type: EventType, predicate: Optional[Callable[[Dict[str, Any]], bool]] = None) -> List[Dict[str, Any]]:
4✔
613
        """Apply a filter to a set of events with a particular event type."""
614
        return list(filter(predicate, self.for_type(event_type)))
4✔
615

616

617
@frozen(kw_only=True)
4✔
618
class ConfirmationRequest(AbstractRequest):
4✔
619
    """Request for CONFIRMATION phase"""
620

621
    app_id: str
4✔
622
    confirmation_data: ConfirmationData
4✔
623
    settings: Dict[str, Any] = field(factory=dict)
4✔
624

625

626
@frozen(kw_only=True)
4✔
627
class ConfirmationResponse:
4✔
628
    """Response for CONFIRMATION phase"""
629

630
    target_url: str
4✔
631

632

633
@frozen(kw_only=True)
4✔
634
class ConfigurationRequest(AbstractRequest):
4✔
635
    """Request for CONFIGURATION phase"""
636

637
    configuration_data: ConfigRequestData
4✔
638
    settings: Dict[str, Any] = field(factory=dict)
4✔
639

640

641
@frozen(kw_only=True)
4✔
642
class ConfigurationInitResponse:
4✔
643
    """Response for CONFIGURATION/INITIALIZE phase"""
644

645
    configuration_data: ConfigInitData
4✔
646

647

648
@frozen(kw_only=True)
4✔
649
class ConfigurationPageResponse:
4✔
650
    """Response for CONFIGURATION/PAGE phase"""
651

652
    configuration_data: ConfigPageData
4✔
653

654

655
@frozen(kw_only=True)
4✔
656
class InstallRequest(AbstractRequest):
4✔
657
    """Request for INSTALL phase"""
658

659
    install_data: InstallData
4✔
660
    settings: Dict[str, Any] = field(factory=dict)
4✔
661

662
    def token(self) -> str:
4✔
663
        """Return the auth token associated with this request."""
664
        return self.install_data.token()
4✔
665

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

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

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

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

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

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

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

694

695
@frozen(kw_only=True)
4✔
696
class InstallResponse:
4✔
697
    """Response for INSTALL phase"""
698

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

701

702
@frozen(kw_only=True)
4✔
703
class UpdateRequest(AbstractRequest):
4✔
704
    """Request for UPDATE phase"""
705

706
    update_data: UpdateData
4✔
707
    settings: Dict[str, Any] = field(factory=dict)
4✔
708

709
    def token(self) -> str:
4✔
710
        """Return the auth token associated with this request."""
711
        return self.update_data.token()
4✔
712

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

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

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

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

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

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

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

741

742
@frozen(kw_only=True)
4✔
743
class UpdateResponse:
4✔
744
    """Response for UPDATE phase"""
745

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

748

749
@frozen(kw_only=True)
4✔
750
class UninstallRequest(AbstractRequest):
4✔
751
    """Request for UNINSTALL phase"""
752

753
    uninstall_data: UninstallData
4✔
754
    settings: Dict[str, Any] = field(factory=dict)
4✔
755

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

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

764

765
@frozen(kw_only=True)
4✔
766
class UninstallResponse:
4✔
767
    """Response for UNINSTALL phase"""
768

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

771

772
@frozen(kw_only=True)
4✔
773
class OauthCallbackRequest(AbstractRequest):
4✔
774
    """Request for OAUTH_CALLBACK phase"""
775

776
    o_auth_callback_data: OauthCallbackData
4✔
777

778

779
@frozen(kw_only=True)
4✔
780
class OauthCallbackResponse:
4✔
781
    """Response for OAUTH_CALLBACK phase"""
782

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

785

786
@frozen(kw_only=True)
4✔
787
class EventRequest(AbstractRequest):
4✔
788
    """Request for EVENT phase"""
789

790
    event_data: EventData
4✔
791
    settings: Dict[str, Any] = field(factory=dict)
4✔
792

793
    def token(self) -> str:
4✔
794
        """Return the auth token associated with this request."""
795
        return self.event_data.token()
4✔
796

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

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

805

806
@frozen(kw_only=True)
4✔
807
class EventResponse:
4✔
808
    """Response for EVENT phase"""
809

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

812

813
LifecycleRequest = Union[
4✔
814
    ConfigurationRequest,
815
    ConfirmationRequest,
816
    InstallRequest,
817
    UpdateRequest,
818
    UninstallRequest,
819
    OauthCallbackRequest,
820
    EventRequest,
821
]
822

823
LifecycleResponse = Union[
4✔
824
    ConfigurationInitResponse,
825
    ConfigurationPageResponse,
826
    ConfirmationResponse,
827
    InstallResponse,
828
    UpdateResponse,
829
    UninstallResponse,
830
    OauthCallbackResponse,
831
    EventResponse,
832
]
833

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

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

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

867

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

872
    message: str
4✔
873
    correlation_id: Optional[str] = None
4✔
874

875

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

880

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

885

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

890

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

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

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

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

910
    check_signatures: bool = True
4✔
911
    clock_skew_sec: Optional[int] = 300
4✔
912
    keyserver_url: str = "https://key.smartthings.com"
4✔
913
    log_json: bool = False
4✔
914

915

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

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

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

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

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

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

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

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

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

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

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

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

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

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

977

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

984
    page_name: str
4✔
985
    sections: List[ConfigSection]
4✔
986

987

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

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

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

1002
    Attributes:
1003
        id(str): Identifier for this SmartApp
1004
        name(str): Name of the SmartApp
1005
        description(str): Description of the SmartApp
1006
        permissions(List[str]): Permissions that the SmartApp requires
1007
        config_pages(List[SmartAppConfigPage]): Configuration pages that the SmartApp will offer users
1008
    """
1009
    id: str
4✔
1010
    name: str
4✔
1011
    description: str
4✔
1012
    target_url: str
4✔
1013
    permissions: List[str]
4✔
1014
    config_pages: Optional[List[SmartAppConfigPage]]
4✔
1015

1016

1017
# pylint: disable=redefined-builtin,unused-argument:
1018
# noinspection PyShadowingBuiltins,PyMethodMayBeStatic
1019
class SmartAppConfigManager(ABC):
4✔
1020
    """
1021
    Configuration manager, used by the dispatcher to respond to CONFIGURATION events.
1022

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

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

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

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

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

1063
    def build_page_response(
4✔
1064
        self,
1065
        page_id: int,
1066
        name: str,
1067
        previous_page_id: Optional[int],
1068
        next_page_id: Optional[int],
1069
        complete: bool,
1070
        sections: List[ConfigSection],
1071
    ) -> ConfigurationPageResponse:
1072
        """Build a ConfigurationPageResponse."""
1073
        return ConfigurationPageResponse(
4✔
1074
            configuration_data=ConfigPageData(
1075
                page=ConfigPage(
1076
                    name=name,
1077
                    page_id=str(page_id),
1078
                    previous_page_id=str(previous_page_id) if previous_page_id else None,
1079
                    next_page_id=str(next_page_id) if next_page_id else None,
1080
                    complete=complete,
1081
                    sections=sections,
1082
                )
1083
            )
1084
        )
1085

1086

1087
# noinspection PyUnresolvedReferences
1088
@frozen(kw_only=True)
4✔
1089
class SmartAppRequestContext:
4✔
1090
    """
1091
    The context for a SmartApp lifecycle request.
1092

1093
    Attributes:
1094
        headers(Mapping[str, str]): The request headers
1095
        body(str): The body of the request as string
1096
    """
1097

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

1102
    headers: Mapping[str, str] = field(factory=dict)
4✔
1103
    body: str = ""
4✔
1104
    normalized: Mapping[str, str] = field(init=False)
4✔
1105
    correlation_id: str = field(init=False)
4✔
1106
    signature: str = field(init=False)
4✔
1107
    date: str = field(init=False)
4✔
1108

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

1114
    @correlation_id.default
4✔
1115
    def _default_correlation_id(self) -> Optional[str]:
4✔
1116
        return self.header(CORRELATION_ID_HEADER)
4✔
1117

1118
    @signature.default
4✔
1119
    def _default_signature(self) -> Optional[str]:
4✔
1120
        return self.header(AUTHORIZATION_HEADER)
4✔
1121

1122
    @date.default
4✔
1123
    def _default_date(self) -> Optional[str]:
4✔
1124
        return self.header(DATE_HEADER)
4✔
1125

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