· 5 years ago · Mar 03, 2021, 09:00 PM
1/**
2 * Sleep Number Controller App
3 *
4 * Usage:
5 * Allows controlling Sleep Number Flexible bases including presence detection.
6 *
7 *-------------------------------------------------------------------------------------------------------------------
8 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
9 * in compliance with the License. You may obtain a copy of the License at:
10 *
11 * http://www.apache.org/licenses/LICENSE-2.0
12 *
13 * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
14 * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
15 * for the specific language governing permissions and limitations under the License.
16 *-------------------------------------------------------------------------------------------------------------------
17 *
18 * If modifying this project, please keep the above header intact and add your comments/credits below - Thank you!
19 *
20 * Thanks to Nathan Jacobson and Tim Parsons for their work on SmartThings apps that do this. This isn't a copy
21 * of those but leverages prior work they've done for the API calls and bed management.
22 * https://github.com/natecj/SmartThings/blob/master/smartapps/natecj/sleepiq-manager.src/sleepiq-manager.groovy
23 * https://github.com/ClassicTim1/SleepNumberManager/blob/master/FlexBase/SmartApp.groovy
24 */
25import groovy.transform.Field
26import java.util.concurrent.ConcurrentLinkedQueue
27import java.util.concurrent.Semaphore
28
29@Field static ConcurrentLinkedQueue requestQueue = new ConcurrentLinkedQueue()
30@Field static Semaphore mutex = new Semaphore(1)
31@Field static Long lastLockTime = 0
32@Field static Long lastErrorLogTime = 0
33
34@Field final String DRIVER_NAME = "Sleep Number Bed"
35@Field final String NAMESPACE = "rvrolyk"
36@Field final String API_HOST = "prod-api.sleepiq.sleepnumber.com"
37@Field final String API_URL = "https://" + API_HOST
38@Field final String USER_AGENT = "SleepIQ/1593766370 CFNetwork/1185.2 Darwin/20.0.0"
39//'''\
40//Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36'''
41
42@Field final ArrayList VALID_ACTUATORS = ["H", "F"]
43@Field final ArrayList VALID_WARMING_TIMES = [30, 60, 120, 180, 240, 300, 360]
44@Field final ArrayList VALID_WARMING_TEMPS = [0, 31, 57, 72]
45@Field final ArrayList VALID_PRESET_TIMES = [0, 15, 30, 45, 60, 120, 180]
46@Field final ArrayList VALID_PRESETS = [1, 2, 3, 4, 5, 6]
47@Field final ArrayList VALID_LIGHT_TIMES = [15, 30, 45, 60, 120, 180]
48@Field final ArrayList VALID_LIGHT_BRIGHTNESS = [1, 30, 100]
49
50definition(
51 name: "Sleep Number Controller",
52 namespace: "rvrolyk",
53 author: "Russ Vrolyk",
54 description: "Control your Sleep Number Flexfit bed.",
55 category: "Integrations",
56 iconUrl: "",
57 iconX2Url: "",
58 importUrl: "https://github.com/rvrolyk/SleepNumberController/blob/master/SleepNumberController_App.groovy"
59)
60
61preferences {
62 page name: "homePage", install: true, uninstall: true
63 page name: "findBedPage"
64 page name: "selectBedPage"
65 page name: "createBedPage"
66 page name: "diagnosticsPage"
67}
68
69/**
70 * Required handler for pause button.
71 */
72def appButtonHandler(btn) {
73 if (btn == "pause") {
74 state.paused = !state.paused
75 if (state.paused) {
76 debug "Paused, unscheduling..."
77 unschedule()
78 unsubscribe()
79 updateLabel()
80 } else {
81 initialize()
82 }
83 }
84}
85
86def homePage() {
87 List currentDevices = getBedDeviceData()
88
89 dynamicPage(name: "homePage") {
90 if (state.paused) {
91 state.pauseButtonName = "Resume"
92 } else {
93 state.pauseButtonName = "Pause"
94 }
95 section("") {
96 input name: "pause", type: "button", title: state.pauseButtonName
97 }
98 section("<b>Settings</b>") {
99 input name: "login", type: "text", title: "sleepnumber.com email",
100 description: "Email address you use with Sleep Number", submitOnChange: true
101 input name: "password", type: "password", title: "sleepnumber.com password",
102 description: "Password you use with Sleep Number", submitOnChange: true
103 // User may opt for constant refresh or a variable one.
104 def defaultVariableRefresh = settings.variableRefresh != null && !settings.variableRefresh ? false : settings.refreshInterval == null
105 input "variableRefresh", "bool", title: "Use variable refresh interval? (recommended)", defaultValue: defaultVariableRefresh,
106 submitOnChange: true
107 if (defaultVariableRefresh || settings.variableRefresh) {
108 input name: "dayInterval", type: "number", title: "Daytime Refresh Interval (minutes)",
109 description: "How often to refresh bed state during the day", defaultValue: 30
110 input name: "nightInterval", type: "number", title: "Nighttime Refresh Interval (minutes)",
111 description: "How often to refresh bed state during the night", defaultValue: 1
112 input "variableRefreshModes", "bool", title: "Use modes to control variable refresh interval", defaultValue: false, submitOnChange: true
113 if (settings.variableRefreshModes) {
114 input name: "nightMode", type: "mode", title: "Modes for night (anything else will be day)", multiple: true, submitOnChange: true
115 } else {
116 input name: "dayStart", type: "time", title: "Day start time",
117 description: "Time when day will start if both sides are out of bed for more than 5 minutes", submitOnChange: true
118 input name: "nightStart", type: "time", title: "Night start time", description: "Time when night will start", submitOnChange: true
119 }
120 } else {
121 input name: "refreshInterval", type: "number", title: "Refresh Interval (minutes)",
122 description: "How often to refresh bed state", defaultValue: 1
123 }
124 }
125
126 section("<b>Bed Management</b>") {
127 if (!settings.login || !settings.password) {
128 paragraph "Add login and password to find beds"
129 } else {
130 if (currentDevices.size() > 0) {
131 paragraph "Current beds"
132 currentDevices.each { device ->
133 String output = ""
134 if (device.isChild) {
135 output += " "
136 } else {
137 output += device.bedId
138 }
139 output += " (<a href=\"/device/edit/${device.deviceId}\">dev:${device.deviceId}</a>) / ${device.name} / ${device.side} / ${device.type}"
140 paragraph output
141 }
142 paragraph "<br>Note: <i>To remove a device remove it from the Devices list</i>"
143 }
144 // Only show bed search if user entered creds
145 if (settings.login && settings.password) {
146 href "findBedPage", title: "Create or Modify Bed", description: "Search for beds"
147 }
148 }
149 }
150
151 section(title: "") {
152 href url: "https://github.com/rvrolyk/SleepNumberController", style: "external", required: false, title: "Documentation", description: "Tap to open browser"
153 }
154
155 section(title: "") {
156 href url: "https://www.paypal.me/rvrolyk", style: "external", required: false, title: "Donations", description: "Tap to open browser for PayPal"
157 }
158
159 section(title: "<b>Advanced Settings</b>") {
160 String defaultName = "Sleep Number Controller"
161 if (state.displayName) {
162 defaultName = state.displayName
163 app.updateLabel(defaultName)
164 }
165 label title: "Assign an app name", required: false, defaultValue: defaultName
166 input name: "modes", type: "mode", title: "Set for specific mode(s)", required: false, multiple: true, submitOnChange: true
167 input "logEnable", "bool", title: "Enable debug logging?", defaultValue: false, required: true, submitOnChange: true
168 input "limitErrorLogsMin", "number", title: "How often to allow error logs (minutes), 0 for all the time", defaultValue: 0, submitOnChange: true
169 if (settings.login && settings.password) {
170 href "diagnosticsPage", title: "Diagnostics", description: "Show diagnostic info"
171 }
172 }
173 }
174}
175
176def installed() {
177 initialize()
178 state.paused = false
179}
180
181def updated() {
182 unsubscribe()
183 unschedule()
184 state.variableRefresh = ""
185 initialize()
186}
187
188def initialize() {
189 if (settings.refreshInterval <= 0 && !settings.variableRefresh) {
190 log.error "Invalid refresh interval ${settings.refreshInterval}"
191 }
192 if (settings.variableRefresh && (settings.dayInterval <= 0 || settings.nightInterval <= 0)) {
193 log.error "Invalid refresh intervals ${settings.dayInterval} or ${settings.nightInterval}"
194 }
195 if (settings.variableRefreshModes) {
196 subscribe(location, "mode", configureVariableRefreshInterval)
197 }
198 setRefreshInterval(0 /* force picking from settings */, "" /* ignored */)
199 initializeBedInfo()
200 refreshChildDevices()
201 updateLabel()
202}
203
204void updateLabel() {
205 // Store the user's original label in state.displayName
206 if (!app.label.contains("<span") && state?.displayName != app.label) {
207 state.displayName = app.label
208 }
209 if (state?.status || state?.paused) {
210 def status = state?.status
211 String label = "${state.displayName} <span style=color:"
212 if (state?.paused) {
213 status = "(Paused)"
214 label += "red"
215 } else if (state.status == "Online") {
216 label += "green"
217 } else if (state.status.contains("Login")) {
218 label += "red"
219 } else {
220 label += "orange"
221 }
222 label += ">${status}</span>"
223 app.updateLabel(label)
224 }
225}
226
227void initializeBedInfo() {
228 debug "Setting up bed info"
229 def info = getBeds()
230 state.bedInfo = [:]
231 info.beds.each() { Map bed ->
232 debug "Bed id ${bed.bedId}"
233 if (!state.bedInfo.containsKey(bed.bedId)) {
234 state.bedInfo[bed.bedId] = [:]
235 }
236 def components = []
237 for (def component : bed.components) {
238 if (component.type == "Base"
239 && component.model.toLowerCase().contains("integrated")) {
240 // Integrated bases need to be treated separately as they don't appear to have
241 // foundation status endpoints so don't lump this with a base type directly.
242 components << "Integrated Base"
243 } else {
244 components << component.type
245 }
246 }
247 state.bedInfo[bed.bedId].components = components
248 }
249 if (!state.bedInfo) {
250 log.warn "No bed state set up"
251 }
252}
253
254/**
255 * Gets all bed child devices even if they're in a virtual container.
256 * Will not return the virtual container(s) or children of a parent
257 * device.
258 */
259List getBedDevices() {
260 List children = []
261 // If any child is a virtual container, iterate that too
262 getChildDevices().each { child ->
263 if (child.hasAttribute("containerSize")) {
264 children.addAll(child.childList())
265 } else {
266 children.add(child)
267 }
268 }
269 return children
270}
271
272/**
273 * Returns a list of maps of all bed devices, even those that are child devices.
274 * The map keys are: name, type, side, deviceId, bedId, isChild
275 */
276List<Map> getBedDeviceData() {
277 // Start with all bed devices.
278 List devices = getBedDevices()
279 List<Map> output = []
280 devices.each { device ->
281 def side = device.getState().side
282 def bedId = device.getState().bedId
283 def type = device.getState()?.type ?: "Parent"
284
285 output << [
286 name: device.label,
287 type: type,
288 side: side,
289 deviceId: device.id,
290 bedId: bedId,
291 isChild: false,
292 ]
293 device.getChildDevices().each { child ->
294 output << [
295 name: child.label,
296 type: device.getChildType(child.deviceNetworkId),
297 side: side,
298 deviceId: child.id,
299 bedId: bedId,
300 isChild: true,
301 ]
302 }
303 }
304 return output
305}
306
307List<String> getBedDeviceTypes() {
308 List data = getBedDeviceData()
309 // TODO: Consider splitting this by side or even by bed.
310 // SKipping for now as most are probably using the same device types
311 // per side and probably only have one bed.
312 return data.collect { it.type }
313}
314
315// Use with #schedule as apparently it's not good to mix #runIn method call
316// and #schedule method call.
317void scheduledRefreshChildDevices() {
318 refreshChildDevices()
319 if (settings.variableRefresh) {
320 // If we're using variable refresh then try to reconfigure it since bed states
321 // have been updated and we may be in daytime.
322 configureVariableRefreshInterval()
323 }
324}
325
326void refreshChildDevices() {
327 log.trace "Refreshing child devices"
328 // Only refresh if mode is a selected one
329 if (settings.modes && !settings.modes.contains(location.mode)) {
330 log.trace "Not refreshing, invalid mode ${location.mode}"
331 return
332 }
333 getBedData()
334 updateLabel()
335}
336
337/**
338 * Called by driver when user triggers poll.
339 */
340void refreshChildDevices(Map ignored, String ignoredDevId) {
341 refreshChildDevices()
342}
343
344/**
345 * Sets the refresh interval or resets to the app setting value if
346 * 0 is given.
347 * Can be used when refresh interval is long (say 15 or 30 minutes) during the day
348 * but quicker, say 1 minute, is desired when presence is first detected or it's
349 * a particular time of day.
350 */
351void setRefreshInterval(BigDecimal val, String ignoredDevId) {
352 debug "setRefreshInterval(${val})"
353 def random = new Random()
354 Integer randomInt = random.nextInt(40) + 4
355 if (val && val > 0) {
356 schedule("${randomInt} /${val} * * * ?", "scheduledRefreshChildDevices")
357 } else {
358 if (!settings.variableRefresh) {
359 debug "Resetting interval to ${settings.refreshInterval}"
360 schedule("${randomInt} /${settings.refreshInterval} * * * ?", "scheduledRefreshChildDevices")
361 } else {
362 configureVariableRefreshInterval()
363 }
364 }
365}
366
367/**
368 * Configures a variable refresh interval schedule so that polling happens slowly
369 * during the day but at night can poll quicker in order to detect things like presence
370 * faster. Daytime will be used if the time is between day and night _and_ no presence
371 * is detected.
372 * If user opted to use modes, this just checks the mode and sets the appropriate polling
373 * based on that.
374 */
375void configureVariableRefreshInterval(evt) {
376 configureVariableRefreshInterval()
377}
378void configureVariableRefreshInterval() {
379 boolean night = false
380
381 if (settings.variableRefreshModes) {
382 if (settings.nightMode.contains(location.mode)) {
383 night = true
384 } else {
385 night = false
386 }
387 } else {
388 // Gather presence state of all child devices
389 List presentChildren = getBedDevices().findAll {
390 (!it.getState().type || it.getState()?.type == "presence") && it.isPresent()
391 }
392 Date now = new Date()
393 if (timeOfDayIsBetween(toDateTime(settings.dayStart), toDateTime(settings.nightStart), now)) {
394 if (presentChildren.size() > 0) return // if someone is still in bed, don't change anything
395 night = false
396 } else {
397 night = true
398 }
399 }
400
401 Random random = new Random()
402 Integer randomInt = random.nextInt(40) + 4
403
404 if (night) {
405 // Don't bother setting the schedule if we are already set to night.
406 if (state.variableRefresh != "night") {
407 log.info "Setting interval to night. Refreshing every ${settings.nightInterval} minutes."
408 schedule("${randomInt} /${settings.nightInterval} * * * ?", "scheduledRefreshChildDevices")
409 state.variableRefresh = "night"
410 }
411 } else if (state.variableRefresh != "day") {
412 log.info "Setting interval to day. Refreshing every ${settings.dayInterval} minutes."
413 schedule("${randomInt} /${settings.dayInterval} * * * ?", "scheduledRefreshChildDevices")
414 state.variableRefresh = "day"
415 }
416}
417
418def findBedPage() {
419 def responseData = getBedData()
420 List devices = getBedDevices()
421 def sidesSeen = []
422 def childDevices = []
423 dynamicPage(name: "findBedPage") {
424 if (responseData.beds.size() > 0) {
425 responseData.beds.each { bed ->
426 section("Bed: ${bed.bedId}") {
427 if (devices.size() > 0) {
428 for (def dev : devices) {
429 if (!dev.getState().type || dev.getState()?.type == "presence") {
430 if (!dev.getState().type) {
431 childDevices << dev.getState().side
432 }
433 sidesSeen << dev.getState().side
434 href "selectBedPage", title: dev.label, description: "Click to modify",
435 params: [bedId: bed.bedId, side: dev.getState().side, label: dev.label]
436 }
437 }
438 if (childDevices.size() < 2) {
439 input "createNewChildDevices", "bool", title: "Create new child device types", defaultValue: false, submitOnChange: true
440 if (settings.createNewChildDevices) {
441 if (!childDevices.contains("Left")) {
442 href "selectBedPage", title: "Left Side", description: "Click to create",
443 params: [bedId: bed.bedId, side: "Left", label: ""]
444 }
445 if (!childDevices.contains("Right")) {
446 href "selectBedPage", title: "Right Side", description: "Click to create",
447 params: [bedId: bed.bedId, side: "Right", label: ""]
448 }
449 }
450 }
451 }
452 if (!sidesSeen.contains("Left")) {
453 href "selectBedPage", title: "Left Side", description: "Click to create",
454 params: [bedId: bed.bedId, side: "Left", label: ""]
455 }
456 if (!sidesSeen.contains("Right")) {
457 href "selectBedPage", title: "Right Side", description: "Click to create",
458 params: [bedId: bed.bedId, side: "Right", label: ""]
459 }
460 }
461 }
462 } else {
463 section {
464 paragraph "No Beds Found"
465 }
466 }
467 }
468}
469
470String presenceText(presence) {
471 return presence ? "Present" : "Not Present"
472}
473
474def selectBedPage(params) {
475 initializeBedInfo()
476 app.updateSetting("newDeviceName", "")
477 dynamicPage(name: "selectBedPage") {
478 if (!params?.bedId) {
479 section {
480 href "homePage", title: "Home", description: null
481 }
482 return
483 }
484 section {
485 paragraph """<b>Instructions</b>
486Enter a name, then choose whether or not to use child devices or a virtual container for the devices and then choose the types of devices to create.
487Note that if using child devices, the parent device will contain all the special commands along with bed specific status while the children are simple
488switches or dimmers. Otherwise, all devices are the same on Hubitat, the only difference is how they behave to dim and on/off commands. This is so that they may be used with external assistants such as Google Assistant or Amazon Alexa. If you don't care about such use cases (and only want RM control or just presence), you can just use the presence type.
489<br>
490See <a href="https://community.hubitat.com/t/release-virtual-container-driver/4440" target=_blank>this post</a> for virtual container.
491"""
492 paragraph """<b>Device information</b>
493Bed ID: ${params.bedId}
494Side: ${params.side}
495"""
496 }
497 section {
498 input "newDeviceName", "text", title: "Device Name", defaultValue: settings.newDeviceName ?: params.label,
499 description: "What prefix do you want for the devices?", submitOnChange: true,
500 required: true
501 input "useChildDevices", "bool", title: "Use child devices? (recommended)", defaultValue: true,
502 submitOnChange: true
503 if (!settings.useChildDevices) {
504 input "useContainer", "bool", title: "Use virtual container?", defaultValue: false,
505 submitOnChange: true
506 }
507 paragraph "A presence type device exposes on/off as switching to a preset level (on) and flat (off). Dimming will change the Sleep Number."
508 if (settings.useChildDevices) {
509 paragraph "This is the parent device when child devices are used"
510 settings.createPresence = true
511 } else {
512 input "createPresence", "bool",
513 title: "Create presence device for ${params.side.toLowerCase()} side?",
514 defaultValue: true, submitOnChange: true
515 }
516 paragraph "A head type device exposes on/off as switching to a preset level (on) and flat (off). Dimming will change the head position (0 is flat, 100 is fully raised)."
517 input "createHeadControl", "bool",
518 title: "Create device to control the head of the ${params.side.toLowerCase()} side?",
519 defaultValue: true, submitOnChange: true
520 paragraph "A foot type device exposes on/off as switching to a preset level (on) and flat (off). Dimming will change the foot position (0 is flat, 100 is fully raised)."
521 input "createFootControl", "bool",
522 title: "Create device to control the foot of the ${params.side.toLowerCase()} side?",
523 defaultValue: true, submitOnChange: true
524 if (state.bedInfo[params.bedId].components.contains("Warming")) {
525 paragraph "A foot type device exposes on/off as switching the foot warming on or off. Dimming will change the heat levels (1: low, 2: medium, 3: high)."
526 input "createFootWarmer", "bool",
527 title: "Create device to control the foot warmer of the ${params.side.toLowerCase()} side?",
528 defaultValue: true, submitOnChange: true
529 }
530 if (settings.useChildDevices) {
531 determineUnderbedLightSetup(params.bedId)
532 paragraph "Underbed lighting creates a dimmer allowing the light to be turned on or off at different levels with timer based on parent device preference."
533 input "createUnderbedLighting", "bool",
534 title: "Create device to control the underbed lighting of the ${params.side.toLowerCase()} side?",
535 defaultValue: false, submitOnChange: true
536 if (state.bedInfo[params.bedId].outlets.size > 1) {
537 paragraph "Outlet creates a switch allowing foundation outlet for this side to be turned on or off."
538 input "createOutlet", "bool",
539 title: "Create device to control the outlet of the ${params.side.toLowerCase()} side?",
540 defaultValue: false, submitOnChange: true
541 }
542 }
543 }
544 section {
545 String msg = "Will create the following devices"
546 def containerName = ""
547 def types = []
548 if (settings.useChildDevices) {
549 settings.useContainer = false
550 msg += " with each side as a primary device and each type as a child device of the side"
551 } else if (settings.useContainer) {
552 containerName = "${newDeviceName} Container"
553 msg += " in virtual container '${containerName}'"
554 }
555 msg += ":<ol>"
556 if (settings.createPresence) {
557 msg += "<li>${createDeviceLabel(newDeviceName, 'presence')}</li>"
558 types.add("presence")
559 }
560 if (settings.createHeadControl) {
561 msg += "<li>${createDeviceLabel(newDeviceName, 'head')}</li>"
562 types.add("head")
563 }
564 if (settings.createFootControl) {
565 msg += "<li>${createDeviceLabel(newDeviceName, 'foot')}</li>"
566 types.add("foot")
567 }
568 if (settings.createFootWarmer) {
569 msg += "<li>${createDeviceLabel(newDeviceName, 'foot warmer')}</li>"
570 types.add("foot warmer")
571 }
572 if (settings.createUnderbedLighting && settings.useChildDevices) {
573 msg += "<li>${createDeviceLabel(newDeviceName, 'underbed light')}</li>"
574 types.add("underbed light")
575 }
576 if (settings.createOutlet && settings.useChildDevices) {
577 msg += "<li>${createDeviceLabel(newDeviceName, 'outlet')}</li>"
578 types.add("outlet")
579 }
580 msg += "</ol>"
581 paragraph msg
582 href "createBedPage", title: "Create Devices", description: null,
583 params: [
584 presence: params.present,
585 bedId: params.bedId,
586 side: params.side,
587 useChildDevices: settings.useChildDevices,
588 useContainer: settings.useContainer,
589 containerName: containerName,
590 types: types
591 ]
592 }
593 }
594}
595
596String createDeviceLabel(String name, String type) {
597 switch (type) {
598 case "presence":
599 return "${name}"
600 case "head":
601 return "${name} Head"
602 case "foot":
603 return "${name} Foot"
604 case "foot warmer":
605 return "${name} Foot Warmer"
606 case "underbed light":
607 return "${name} Underbed Light"
608 case "outlet":
609 return "${name} Outlet"
610 default:
611 return "${name} Unknown"
612 }
613}
614
615def createBedPage(params) {
616 def container = null
617 if (params.useContainer) {
618 container = createContainer(params.bedId, params.containerName, params.side)
619 }
620 List existingDevices = getBedDevices()
621 List devices = []
622 // TODO: Consider allowing more than one identical device for debug purposes.
623 if (params.useChildDevices) {
624 // Bed Ids seem to always be negative so convert to positive for the device
625 // id for better formatting.
626 def bedId = Math.abs(Long.valueOf(params.bedId))
627 def deviceId = "sleepnumber.${bedId}.${params.side}"
628 def label = createDeviceLabel(settings.newDeviceName, "presence")
629 def parent = existingDevices.find{ it.deviceNetworkId == deviceId }
630 if (parent) {
631 log.info "Parent device ${deviceId} already exists"
632 } else {
633 debug "Creating parent device ${deviceId}"
634 parent = addChildDevice(NAMESPACE, DRIVER_NAME, deviceId, null, [label: label])
635 parent.setStatus(params.presence)
636 parent.setBedId(params.bedId)
637 parent.setSide(params.side)
638 devices.add(parent)
639 }
640 // If we are using child devices then we create a presence device and
641 // all others are children of it.
642 params.types.each { type ->
643 if (type != "presence") {
644 def childId = deviceId + "-" + type.replaceAll(" ", "")
645 switch (type) {
646 case "outlet":
647 driverType = "Switch"
648 break
649 case "head":
650 case "foot":
651 case "foot warmer":
652 case "underbed light":
653 driverType = "Dimmer"
654 }
655 def newDevice = parent.createChildDevice(childId, "Generic Component ${driverType}",
656 createDeviceLabel(settings.newDeviceName, type))
657 if (newDevice) {
658 devices.add(newDevice)
659 }
660 }
661 }
662 } else {
663 params.types.each { type ->
664 def deviceId = "sleepnumber.${params.bedId}.${params.side}.${type.replaceAll(' ', '_')}"
665 if (existingDevices.find{ it.data.vcId == deviceId }) {
666 log.info "Not creating device ${deviceId}, it already exists"
667 } else {
668 def label = createDeviceLabel(settings.newDeviceName, type)
669 def device = null
670 if (container) {
671 debug "Creating new child device ${deviceId} with label ${label} in container ${params.containerName}"
672 container.appCreateDevice(label, DRIVER_NAME, NAMESPACE, deviceId)
673 // #appCreateDevice doesn't return the device so find it
674 device = container.childList().find({it.data.vcId == deviceId})
675 } else {
676 device = addChildDevice(NAMESPACE, DRIVER_NAME, deviceId, null, [label: label])
677 }
678 device.setStatus(params.presence)
679 device.setBedId(params.bedId)
680 device.setSide(params.side)
681 device.setType(type)
682 devices.add(device)
683 }
684 }
685 }
686 // Reset the bed info since we added more.
687 initializeBedInfo()
688 settings.newDeviceName = null
689 dynamicPage(name: "selectDevicePage") {
690 section {
691 def header = "Created new devices"
692 if (params.useChildDevices) {
693 header += " using child devices"
694 } else if (params.useContainer) {
695 header += " in container ${params.containerName}"
696 }
697 header += ":"
698 paragraph(header)
699 def info = "<ol>"
700 devices.each { device ->
701 info += "<li>"
702 info += "${device.label}"
703 if (!params.useChildDevices) {
704 info += "<br>Bed ID: ${device.getState().bedId}"
705 info += "<br>Side: ${device.getState().side}"
706 info += "<br>Type: ${device.getState()?.type}"
707 }
708 info += "</li>"
709 }
710 info += "</ol>"
711 paragraph info
712 }
713 section {
714 href "findBedPage", title: "Back to Bed List", description: null
715 }
716 }
717}
718
719def diagnosticsPage(params) {
720 def info = getBeds()
721 dynamicPage(name: "diagnosticsPage") {
722 info.beds.each { Map bed ->
723 section("Bed: ${bed.bedId}") {
724 def bedOutput = "<ul>"
725 bedOutput += "<li>Size: ${bed.size}"
726 bedOutput += "<li>Dual Sleep: ${bed.dualSleep}"
727 bedOutput += "<li>Components:"
728 for (def component : bed.components) {
729 bedOutput += "<ul>"
730 bedOutput += "<li>Type: ${component.type}"
731 bedOutput += "<li>Status: ${component.status}"
732 bedOutput += "<li>Model: ${component.model}"
733 bedOutput += "</ul>"
734 }
735 paragraph bedOutput
736 }
737 }
738 section("Send Requests") {
739 input "requestType", "enum", title: "Request type", options: ["PUT", "GET"]
740 input "requestPath", "text", title: "Request path", description: "Full path including bed id if needed"
741 input "requestBody", "text", title: "Request Body in JSON"
742 input "requestQuery", "text", title: "Extra query key/value pairs in JSON"
743 href "diagnosticsPage", title: "Send request", description: null, params: [
744 requestType: requestType,
745 requestPath: requestPath,
746 requestBody: requestBody,
747 requestQuery: requestQuery
748 ]
749 if (params && params.requestPath && params.requestType) {
750 Map body
751 if (params.requestBody) {
752 try {
753 body = parseJson(params.requestBody)
754 } catch (groovy.json.JsonException e) {
755 maybeLogError "${params.requestBody} : ${e}"
756 }
757 }
758 Map query
759 if (params.requestQuery) {
760 try {
761 query = parseJson(params.requestQuery)
762 } catch (groovy.json.JsonException e) {
763 maybeLogError "${params.requestQuery} : ${e}"
764 }
765 }
766 def response = httpRequest((String)params.requestPath,
767 requestType == "PUT" ? this.&put : this.&get,
768 body,
769 query,
770 true)
771 paragraph "${response}"
772 }
773 }
774 }
775}
776
777/**
778 * Creates a virtual container with the given name and side
779 */
780def createContainer(String bedId, String containerName, String side) {
781 def container = getChildDevices().find{it.typeName == "Virtual Container" && it.label == containerName}
782 if(!container) {
783 log.trace "Creating container ${containerName}"
784 try {
785 container = addChildDevice("stephack", "Virtual Container", "${app.id}.${bedId}.${side}", null,
786 [name: containerName, label: containerName, completedSetup: true])
787 } catch (e) {
788 log.error "Container device creation failed with error = ${e}"
789 return null
790 }
791 }
792 return container
793}
794
795def getBedData() {
796 log.trace "Getting family status"
797 def responseData = getFamilyStatus()
798 log.trace "Response: ${responseData}"
799 processBedData(responseData)
800 return responseData
801}
802
803/**
804 * Updates the bed devices with the given data.
805 */
806def processBedData(Map responseData) {
807 if (!responseData || responseData.size() == 0) {
808 debug "Empty response data"
809 return
810 }
811 debug "Response data from SleepNumber: ${responseData}"
812 // cache for foundation status per bed id so we don't have to run the api call N times
813 def foundationStatus = [:]
814 def footwarmingStatus = [:]
815 def privacyStatus = [:]
816 def bedFailures = [:]
817 def loggedError = [:]
818 def sleepNumberFavorites = [:]
819 def outletData = [:]
820 def underbedLightData = [:]
821
822 List deviceTypes = getBedDeviceTypes()
823
824 for (def device : getBedDevices()) {
825 String bedId = device.getState().bedId.toString()
826 String bedSideStr = device.getState().side
827 if (!outletData.get(bedId)) {
828 outletData[bedId] = [:]
829 underbedLightData[bedId] = [:]
830 }
831
832 for (def bed : (List)responseData.beds) {
833 // Make sure the various bed state info is set up so we can use it later.
834 if (!state?.bedInfo || !state?.bedInfo[bed.bedId] || !state?.bedInfo[bed.bedId]?.components) {
835 log.warn "state.bedInfo somehow lost, re-caching it"
836 initializeBedInfo()
837 }
838 if (bedId == bed.bedId) {
839 if (!bedFailures.get(bedId) && !privacyStatus.get(bedId)) {
840 privacyStatus[bedId] = getPrivacyMode(bedId)
841 if (!privacyStatus.get(bed.bedId)) {
842 bedFailures[bedId] = true
843 }
844 }
845 // Note that it is possible to have a mattress without the base. Prior, this used the presence of "Base"
846 // in the bed status but it turns out SleepNumber doesn't always include that even when the base is
847 // adjustable. So instead, this relies on the devices the user created.
848 if (!bedFailures.get(bedId)
849 && !foundationStatus.get(bedId)
850 && (deviceTypes.contains("head") || deviceTypes.contains("foot"))) {
851 foundationStatus[bedId] = getFoundationStatus(bedId, bedSideStr)
852 if (!foundationStatus.get(bedId)) {
853 bedFailures[bedId] = true
854 }
855 }
856 // So far, the presence of "Warming" in the bed status indicates a foot warmer.
857 if (!bedFailures.get(bedId)
858 && !footwarmingStatus.get(bedId)
859 && state.bedInfo[bedId].components.contains("Warming")
860 && (deviceTypes.contains("foot warmer") || deviceTypes.contains("footwarmer"))) {
861 // Only try to update the warming state if the bed actually has it
862 // and there's a device for it.
863 footwarmingStatus[bedId] = getFootWarmingStatus(bedId)
864 if (!footwarmingStatus.get(bedId)) {
865 bedFailures[bedId] = true
866 }
867 }
868 // If there's underbed lighting or outlets then poll for that data as well. Don't poll
869 // otherwise since it's just another network request and may be unwanted.
870 if (!bedFailures.get(bedId) && deviceTypes.contains("underbedlight")) {
871 determineUnderbedLightSetup(bedId)
872 if (!outletData[bedId][3]) {
873 outletData[bedId][3] = getOutletState(bedId, 3)
874 if (!outletData[bedId][3]) {
875 bedFailures[bedId] = true
876 }
877 }
878 if (!bedFailures.get(bedId) && !underbedLightData[bedId]) {
879 underbedLightData[bedId] = getUnderbedLightState(bedId)
880 if (!underbedLightData.get(bedId)) {
881 bedFailures[bedId] = true
882 } else {
883 def brightnessData = getUnderbedLightBrightness(bedId)
884 if (!brightnessData) {
885 bedFailures[bedId] = true
886 } else {
887 underbedLightData[bedId] << brightnessData
888 }
889 }
890 }
891 if (state.bedInfo[bedId].outlets.size() > 1) {
892 if (!bedFailures.get(bedId) && !outletData[bedId][4]) {
893 outletData[bedId][4] = getOutletState(bedId, 4)
894 if (!outletData[bedId][4]) {
895 bedFailures[bedId] = true
896 }
897 }
898 } else {
899 outletData[bed.bedId][4] = outletData[bed.bedId][3]
900 }
901 }
902 if (!bedFailures.get(bedId) && deviceTypes.contains("outlet")) {
903 if (!outletData[bedId][1]) {
904 outletData[bedId][1] = getOutletState(bedId, 1)
905 if (!outletData[bedId][1]) {
906 bedFailures[bedId] = true
907 } else {
908 outletData[bedId][2] = getOutletState(bedId, 2)
909 if (!outletData[bedId][2]) {
910 bedFailures[bedId] = true
911 }
912 }
913 }
914 }
915
916 def bedSide = bedSideStr == "Right" ? bed.rightSide : bed.leftSide
917 device.setPresence(bedSide.isInBed)
918 def statusMap = [
919 sleepNumber: bedSide.sleepNumber,
920 privacyMode: privacyStatus[bedId],
921 ]
922 if (underbedLightData.get(bedId)) {
923 Integer outletNumber = bedSideStr == "Left" ? 3 : 4
924 String bstate = underbedLightData[bedId]?.enableAuto ? "Auto" :
925 outletData[bedId][outletNumber]?.setting == 1 ? "On" : "Off"
926 String timer = bstate == "Auto" ? "Not set" :
927 outletData[bedId][outletNumber]?.timer ? outletData[bedId][outletNumber]?.timer : "Forever"
928 def brightness = underbedLightData[bedId]?."fs${bedSideStr}UnderbedLightPWM"
929 statusMap << [
930 underbedLightState: bstate,
931 underbedLightTimer: timer,
932 underbedLightBrightness: brightness,
933 ]
934 }
935 if (outletData.get(bedId) && outletData[bedId][1]) {
936 Integer outletNumber = bedSideStr == "Left" ? 1 : 2
937 statusMap << [
938 outletState: outletData[bedId][outletNumber]?.setting == 1 ? "On" : "Off"
939 ]
940 }
941 // Check for valid foundation status and footwarming status data before trying to use it
942 // as it's possible the HTTP calls failed.
943 if (foundationStatus.get(bedId)) {
944 // Positions are in hex so convert to a decimal
945 def headPosition = convertHexToNumber(foundationStatus.get(bedId)."fs${bedSideStr}HeadPosition")
946 def footPosition = convertHexToNumber(foundationStatus.get(bed.bedId)."fs${bedSideStr}FootPosition")
947 def bedPreset = foundationStatus.get(bedId)."fsCurrentPositionPreset${bedSideStr}"
948 // There's also a MSB timer but not sure when that gets set. Least significant bit seems used for all valid times.
949 def positionTimer = convertHexToNumber(foundationStatus.get(bedId)."fs${bedSideStr}PositionTimerLSB")
950 statusMap << [
951 headPosition: headPosition,
952 footPosition: footPosition,
953 positionPreset: bedPreset,
954 positionPresetTimer: foundationStatus.get(bedId)."fsTimerPositionPreset${bedSideStr}",
955 positionTimer: positionTimer
956 ]
957 } else if (!loggedError.get(bedId)) {
958 debug "Not updating foundation state, " + (bedFailures.get(bedId) ? "error making requests" : "no data")
959 }
960 if (footwarmingStatus.get(bedId)) {
961 statusMap << [
962 footWarmingTemp: footwarmingStatus.get(bedId)."footWarmingStatus${bedSideStr}",
963 footWarmingTimer: footwarmingStatus.get(bedId)."footWarmingTimer${bedSideStr}",
964 ]
965 } else if (!loggedError.get(bedId)) {
966 debug "Not updating footwarming state, " + (bedFailures.get(bedId) ? "error making requests" : "no data")
967 }
968 if (!sleepNumberFavorites.get(bedId)) {
969 sleepNumberFavorites[bedId] = getSleepNumberFavorite(bedId)
970 }
971 def favorite = sleepNumberFavorites.get(bedId).get("sleepNumberFavorite" + bedSideStr, -1)
972 if (favorite >= 0) {
973 statusMap << [
974 sleepNumberFavorite: favorite
975 ]
976 }
977 if (bedFailures.get(bedId)) {
978 // Only log update errors once per bed
979 loggedError[bedId] = true
980 }
981 device.setStatus(statusMap)
982 break
983 }
984 }
985 }
986 if (bedFailures.size() == 0) {
987 state.status = "Online"
988 }
989 debug "Cached data: ${foundationStatus}\n${footwarmingStatus}"
990}
991
992def convertHexToNumber(value) {
993 if (value == "" || value == null) return 0
994 try {
995 return Integer.parseInt(value, 16)
996 } catch (Exception e) {
997 log.error "Failed to convert non-numeric value ${value}: ${e}"
998 return value
999 }
1000}
1001
1002def getBeds() {
1003 debug "Getting information for all beds"
1004 return httpRequest("/rest/bed")
1005}
1006
1007def getFamilyStatus() {
1008 debug "Getting family status"
1009 return httpRequest("/rest/bed/familyStatus")
1010}
1011
1012def getFoundationStatus(String bedId, String currentSide) {
1013 debug "Getting Foundation Status for ${bedId} / ${currentSide}"
1014 return httpRequest("/rest/bed/${bedId}/foundation/status")
1015}
1016
1017def getFootWarmingStatus(String bedId) {
1018 debug "Getting Foot Warming Status for ${bedId}"
1019 return httpRequest("/rest/bed/${bedId}/foundation/footwarming")
1020}
1021
1022/**
1023 * Params must be a Map containing keys actuator and position.
1024 * The side is derived from the specified device.
1025 */
1026void setFoundationAdjustment(Map params, String devId) {
1027 def device = getBedDevices().find { devId == it.deviceNetworkId }
1028 if (!device) {
1029 log.error "Bed device with id ${devId} is not a valid child"
1030 return
1031 }
1032 if (!params?.actuator || params?.position == null) {
1033 log.error "Missing param values, actuator and position are required"
1034 return
1035 }
1036 if (!VALID_ACTUATORS.contains(params.actuator)) {
1037 log.error "Invalid actuator ${params.actuator}, valid values are ${VALID_ACTUATORS}"
1038 return
1039 }
1040 Map body = [
1041 speed: 0,
1042 actuator: params.actuator,
1043 side: device.getState().side[0],
1044 position: params.position
1045 ]
1046 // It takes ~35 seconds for a FlexFit3 head to go from 0-100 (or back) and about 18 seconds for the foot.
1047 // The timing appears to be linear which means it's 0.35 seconds per level adjusted for the head and 0.18
1048 // for the foot.
1049 int currentPosition = params.actuator == "H" ? device.currentValue("headPosition") : device.currentValue("footPosition")
1050 int positionDelta = Math.abs(params.position - currentPosition)
1051 float movementDuration = params.actuator == "H" ? 0.35 : 0.18
1052 int waitTime = Math.round(movementDuration * positionDelta) + 1
1053 httpRequestQueue(waitTime, path: "/rest/bed/${device.getState().bedId}/foundation/adjustment/micro",
1054 body: body, runAfter: "refreshChildDevices")
1055}
1056
1057/**
1058 * Params must be a Map containing keys temp and timer.
1059 * The side is derived from the specified device.
1060 */
1061void setFootWarmingState(Map params, String devId) {
1062 def device = getBedDevices().find { devId == it.deviceNetworkId }
1063 if (!device) {
1064 log.error "Bed device with id ${devId} is not a valid child"
1065 return
1066 }
1067 if (params?.temp == null || params?.timer == null) {
1068 log.error "Missing param values, temp and timer are required"
1069 return
1070 }
1071 if (!VALID_WARMING_TIMES.contains(params.timer)) {
1072 log.error "Invalid warming time ${params.timer}, valid values are ${VALID_WARMING_TIMES}"
1073 return
1074 }
1075 if (!VALID_WARMING_TEMPS.contains(params.temp)) {
1076 log.error "Invalid warming temp ${params.temp}, valid values are ${VALID_WARMING_TEMPS}"
1077 return
1078 }
1079 Map body = [
1080 "footWarmingTemp${device.getState().side}": params.temp,
1081 "footWarmingTimer${device.getState().side}": params.timer
1082 ]
1083 // Shouldn't take too long for the bed to reflect the new state, wait 5s just to be safe
1084 httpRequestQueue(5, path: "/rest/bed/${device.getState().bedId}/foundation/footwarming",
1085 body: body, runAfter: "refreshChildDevices")
1086}
1087
1088/**
1089 * Params must be a map containing keys preset and timer.
1090 * The side is derived from the specified device.
1091 */
1092def setFoundationTimer(Map params, String devId) {
1093 def device = getBedDevices().find { devId == it.deviceNetworkId }
1094 if (!device) {
1095 log.error "Bed device with id ${devId} is not a valid child"
1096 return
1097 }
1098 if (params?.preset == null || params?.timer == null) {
1099 log.error "Missing param values, preset and timer are required"
1100 return
1101 }
1102 if (!VALID_PRESETS.contains(params.preset)) {
1103 log.error "Invalid preset ${params.preset}, valid values are ${VALID_PRESETS}"
1104 return
1105 }
1106 if (!VALID_PRESET_TIMES.contains(params.timer)) {
1107 log.error "Invalid timer ${params.timer}, valid values are ${VALID_PRESET_TIMES}"
1108 return
1109 }
1110 Map body = [
1111 side: device.getState().side[0],
1112 positionPreset: params.preset,
1113 positionTimer: params.timer
1114 ]
1115 httpRequest("/rest/bed/${device.getState().bedId}/foundation/adjustment", this.&put, body)
1116 // Shouldn't take too long for the bed to reflect the new state, wait 5s just to be safe
1117 runIn(5, "refreshChildDevices")
1118}
1119
1120/**
1121 * The side is derived from the specified device.
1122 */
1123def setFoundationPreset(Integer preset, String devId) {
1124 def device = getBedDevices().find { devId == it.deviceNetworkId }
1125 if (!device) {
1126 log.error "Bed device with id ${devId} is not a valid child"
1127 return
1128 }
1129 if (!VALID_PRESETS.contains(preset)) {
1130 log.error "Invalid preset ${preset}, valid values are ${VALID_PRESETS}"
1131 return
1132 }
1133 Map body = [
1134 speed: 0,
1135 preset : preset,
1136 side: device.getState().side[0],
1137 ]
1138 // It takes ~35 seconds for a FlexFit3 head to go from 0-100 (or back) and about 18 seconds for the foot.
1139 // Rather than attempt to derive the preset relative to the current state so we can compute
1140 // the time (as we do for adjustment), we just use the maximum.
1141 httpRequestQueue(35, path: "/rest/bed/${device.getState().bedId}/foundation/preset",
1142 body: body, runAfter: "refreshChildDevices")
1143}
1144
1145def stopFoundationMovement(Map ignored, String devId) {
1146 def device = getBedDevices().find { devId == it.deviceNetworkId }
1147 if (!device) {
1148 log.error "Bed device with id ${devId} is not a valid child"
1149 return
1150 }
1151 Map body = [
1152 massageMotion: 0,
1153 headMotion: 1,
1154 footMotion: 1,
1155 side: device.getState().side[0],
1156 ]
1157 httpRequest("/rest/bed/${device.getState().bedId}/foundation/motion", this.&put, body)
1158 runIn(5, "refreshChildDevices")
1159}
1160
1161/**
1162 * The side is derived from the specified device.
1163 */
1164def setSleepNumber(BigDecimal number, String devId) {
1165 def device = getBedDevices().find { devId == it.deviceNetworkId }
1166 if (!device) {
1167 log.error "Bed device with id ${devId} is not a valid child"
1168 return
1169 }
1170
1171 Map body = [
1172 bedId: device.getState().bedId,
1173 sleepNumber: number,
1174 side: device.getState().side[0]
1175 ]
1176 // Not sure how long it takes to inflate or deflate so just wait 20s
1177 httpRequestQueue(20, path: "/rest/bed/${device.getState().bedId}/sleepNumber",
1178 body: body, runAfter: "refreshChildDevices")
1179}
1180
1181def getPrivacyMode(String bedId) {
1182 debug "Getting Privacy Mode for ${bedId}"
1183 return httpRequest("/rest/bed/${bedId}/pauseMode", this.&get)?.pauseMode
1184}
1185
1186def setPrivacyMode(Boolean mode, String devId) {
1187 def device = getBedDevices().find { devId == it.deviceNetworkId }
1188 if (!device) {
1189 log.error "Bed device with id ${devId} is not a valid child"
1190 return
1191 }
1192 def pauseMode = mode ? "on" : "off"
1193 // Cloud request so no need to queue.
1194 httpRequest("/rest/bed/${device.getState().bedId}/pauseMode", this.&put, null, [mode: pauseMode])
1195 runIn(2, "refreshChildDevices")
1196}
1197
1198def getSleepNumberFavorite(String bedId) {
1199 debug "Getting Sleep Number Favorites"
1200 return httpRequest("/rest/bed/${bedId}/sleepNumberFavorite", this.&get)
1201}
1202
1203def setSleepNumberFavorite(String ignored, String devId) {
1204 def device = getBedDevices().find { devId == it.deviceNetworkId }
1205 if (!device) {
1206 log.error "Bed device with id ${devId} is not a valid child"
1207 return
1208 }
1209 // Get the favorite for the device first, the most recent poll should be accurate
1210 // enough.
1211 def favorite = device.currentValue("sleepNumberFavorite")
1212 debug "sleep number favorite for ${device.getState().side} is ${favorite}"
1213 if (!favorite || favorite < 0) {
1214 log.error "Unable to determine sleep number favorite for side ${device.getState().side}"
1215 return
1216 }
1217 if (device.currentValue("sleepNumber") == favorite) {
1218 debug "Already at favorite"
1219 return
1220 }
1221 setSleepNumber(favorite, devId)
1222}
1223
1224def getOutletState(String bedId, Integer outlet) {
1225 return httpRequest("/rest/bed/${bedId}/foundation/outlet",
1226 this.&get, null, [outletId: outlet])
1227}
1228
1229def setOutletState(String outletState, String devId) {
1230 def device = getBedDevices().find { devId == it.deviceNetworkId }
1231 if (!device) {
1232 log.error "Bed device with id ${devId} is not a valid child"
1233 return
1234 }
1235 if (!outletState) {
1236 log.error "Missing outletState"
1237 return
1238 }
1239 def outletNum = device.getState().side == "Left" ? 1 : 2
1240 setOutletState(device.getState().bedId, outletNum, outletState)
1241}
1242
1243/**
1244 * Sets the state of the given outlet.
1245 * @param bedId: the bed id
1246 * @param outletId: 1-4
1247 * @param state: on or off
1248 * @param timer: a valid minute duration (for outlets 3 and 4 only)
1249 * Timer is the only optional parameter.
1250 */
1251def setOutletState(String bedId, Integer outletId, String outletState, Integer timer = null) {
1252 if (!bedId || !outletId || !outletState) {
1253 log.error "Not all required arguments present"
1254 return
1255 }
1256
1257 if (timer && !VALID_LIGHT_TIMES.contains(timer)) {
1258 log.error "Invalid underbed light timer ${timer}. Valid values are ${VALID_LIGHT_TIMES}"
1259 return
1260 }
1261
1262 outletState = (outletState ?: "").toLowerCase()
1263
1264 if (outletId < 3) {
1265 // No timer is valid for outlets other than 3 and 4
1266 timer = null
1267 } else {
1268 timer = timer ?: 0
1269 }
1270 Map body = [
1271 timer: timer,
1272 setting: outletState == "on" ? 1 : 0,
1273 outletId: outletId
1274 ]
1275 httpRequestQueue(5, path: "/rest/bed/${bedId}/foundation/outlet",
1276 body: body, runAfter: "refreshChildDevices")
1277}
1278
1279def getUnderbedLightState(String bedId) {
1280 httpRequest("/rest/bed/${bedId}/foundation/underbedLight", this.&get)
1281}
1282
1283def getUnderbedLightBrightness(String bedId) {
1284 determineUnderbedLightSetup(bedId)
1285 def brightness = httpRequest("/rest/bed/${bedId}/foundation/system", this.&get)
1286 if (state.bedInfo[bedId].outlets.size() <= 1) {
1287 // Strangely if there's only one light then the `right` side is the set value
1288 // so just set them both the same.
1289 brightness.fsLeftUnderbedLightPWM = brightness.fsRightUnderbedLightPWM
1290 }
1291 return brightness
1292}
1293
1294/**
1295 * Determines how many underbed light exists and sets up state.
1296 */
1297def determineUnderbedLightSetup(String bedId) {
1298 if (!state.bedInfo[bedId].outlets) {
1299 debug "Determining underbed lighting outlets for ${bedId}"
1300 // Determine if this bed has 1 or 2 underbed lighting outlets and store for future use.
1301 def outlet3 = getOutletState(bedId, 3)
1302 def outlet4 = getOutletState(bedId, 4)
1303 def outlets = []
1304 if (outlet3) {
1305 outlets << 3
1306 }
1307 if (outlet4) {
1308 outlets << 4
1309 }
1310 state.bedInfo[bedId].outlets = outlets
1311 }
1312}
1313
1314/**
1315 * Sets the underbed lighting per given params.
1316 * If only timer is given, state is assumed to be `on`.
1317 * If the foundation has outlet 3 and 4 then the bed side
1318 * will be used to determine which to enable.
1319 * The params map must include:
1320 * state: on, off, auto
1321 * And may include:
1322 * timer: valid minute duration
1323 * brighness: low, medium, high
1324 */
1325def setUnderbedLightState(Map params, String devId) {
1326 def device = getBedDevices().find { devId == it.deviceNetworkId }
1327 if (!device) {
1328 log.error "Bed device with id ${devId} is not a valid child"
1329 return
1330 }
1331
1332 if (!params.state) {
1333 log.error "Missing param state"
1334 return
1335 }
1336
1337 params.state = params.state.toLowerCase()
1338
1339 // A timer with a state of auto makes no sense, choose to honor state vs. timer.
1340 if (params.state == "auto") {
1341 params.timer = 0
1342 }
1343 if (params.timer) {
1344 params.state = "on"
1345 }
1346
1347 if (params.brightness && !VALID_LIGHT_BRIGHTNESS.contains(params.brightness)) {
1348 log.error "Invalid underbed light brightness ${params.brightness}. Valid values are ${VALID_LIGHT_BRIGHTNESS}"
1349 return
1350 }
1351
1352 // First set the light state.
1353 Map body = [
1354 enableAuto: params.state == "auto"
1355 ]
1356 httpRequest("/rest/bed/${device.getState().bedId}/foundation/underbedLight", this.&put, body)
1357
1358 determineUnderbedLightSetup(device.getState().bedId)
1359 def rightBrightness = params.brightness
1360 def leftBrightness = params.brightness
1361 def outletNum = 3
1362 if (state.bedInfo[device.getState().bedId].outlets.size() > 1) {
1363 // Two outlets so set the side corresponding to the device rather than
1364 // defaulting to 3 (which should be a single light)
1365 if (device.getState().side == "Left") {
1366 outletNum = 3
1367 rightBrightness = null
1368 leftBrightness = params.brightness
1369 } else {
1370 outletNum = 4
1371 rightBrightness = params.brightness
1372 leftBrightness = null
1373 }
1374 }
1375 log.trace("State: ${params.state}")
1376 setOutletState(device.getState().bedId, outletNum,
1377 params.state == "auto" ? "off" : params.state, params.timer)
1378
1379 // If brightness was given then set it.
1380 if (params.brightness) {
1381 body = [
1382 rightUnderbedLightPWM: rightBrightness,
1383 leftUnderbedLightPWM: leftBrightness
1384 ]
1385 httpRequest("/rest/bed/${device.getState().bedId}/foundation/system", this.&put, body)
1386 }
1387 runIn(10, "refreshChildDevices")
1388}
1389
1390
1391Map getSleepData(Map ignored, String devId) {
1392 def device = getBedDevices().find { devId == it.deviceNetworkId }
1393 if (!device) {
1394 log.error "Bed device with id ${devId} is not a valid child"
1395 return
1396 }
1397 def bedId = device.getState().bedId
1398 def ids = [:]
1399 // We need a sleeper id for the side in order to look up sleep data.
1400 // Get sleeper to get list of sleeper ids
1401 debug "Getting sleeper ids for ${bedId}"
1402 def sleepers = httpRequest("/rest/sleeper", this.&get)
1403 sleepers.sleepers.each() { sleeper ->
1404 if (sleeper.bedId == bedId) {
1405 def side
1406 switch (sleeper.side) {
1407 case 0:
1408 side = "Left"
1409 break
1410 case 1:
1411 side = "Right"
1412 break
1413 default:
1414 log.warn "Unknown sleeper info: ${sleeper}"
1415 }
1416 if (side) {
1417 ids[side] = sleeper.sleeperId
1418 }
1419 }
1420 }
1421
1422 debug "Getting sleep data for ${ids[device.getState().side]}"
1423 // Interval can be W1 for a week, D1 for a day and M1 for a month.
1424 return httpRequest("/rest/sleepData", this.&get, null, [
1425 interval: "D1",
1426 sleeper: ids[device.getState().side],
1427 includeSlices: false,
1428 date: new Date().format("yyyy-MM-dd'T'HH:mm:ss")
1429 ])
1430}
1431
1432void login() {
1433 debug "Logging in"
1434 state.session = null
1435 try {
1436 Map params = [
1437 uri: API_URL + "/rest/login",
1438 requestContentType: "application/json",
1439 contentType: "application/json",
1440 headers: [
1441 "Host": API_HOST,
1442 "User-Agent": USER_AGENT,
1443 "DNT": "1",
1444 ],
1445 body: "{'login':'${settings.login}', 'password':'${settings.password}'}=",
1446 timeout: 20
1447 ]
1448 httpPut(params) { response ->
1449 if (response.success) {
1450 debug "login Success: (${response.status}) ${response.data}"
1451 state.session = [:]
1452 state.session.key = response.data.key
1453 state.session.cookies = ""
1454 response.getHeaders("Set-Cookie").each {
1455 state.session.cookies = state.session.cookies + it.value.split(";")[0] + ";"
1456 }
1457 } else {
1458 maybeLogError "login Failure: (${response.status}) ${response.data}"
1459 state.status = "Login Error"
1460 }
1461 }
1462 } catch (Exception e) {
1463 maybeLogError "login Error: ${e}"
1464 state.status = "Login Error"
1465 }
1466}
1467
1468/**
1469 * Adds a PUT HTTP request to the queue with the expectation that it will take approximaly `duration`
1470 * time to run. This means other enqueued requests may run after `duration`.
1471 * Args may be:
1472 * body: Map
1473 * query: Map
1474 * path: String
1475 * runAfter: String (name of handler method to run after delay)
1476 */
1477void httpRequestQueue(Map args, int duration) {
1478 // Creating new classes appears to be forbidden so instead we just use a map to represent the
1479 // HTTP request data we want to persist in the queue.
1480 Map request = [
1481 duration: duration,
1482 path: args.path,
1483 body: args.body,
1484 query: args.query,
1485 runAfter: args.runAfter,
1486 ]
1487 requestQueue.add(request)
1488 handleRequestQueue()
1489}
1490
1491// Only this method should be setting releaseLock to true.
1492void handleRequestQueue(boolean releaseLock = false) {
1493 if (releaseLock) mutex.release()
1494 if (requestQueue.isEmpty()) return
1495 // Get the oldest request in the queue to run.
1496 try {
1497 if (!mutex.tryAcquire()) {
1498 // If we can't obtain the lock it means one of two things:
1499 // 1. There's an existing operation and we should rightly skip. In this case,
1500 // the last thing the method does is re-run itself so this will clear itself up.
1501 // 2. There's an unintended failure which has lead to a failed lock release. We detect
1502 // this by checking the last time the lock was held and releasing the mutex if it's
1503 // been too long.
1504 // RACE HERE. if lock time hasnt been updsted in this thread yet it will incorrectly move forward
1505 if ((now() - lastLockTime) > 120000 /* 2 minutes */) {
1506 // Due to potential race setting and reading the lock time,
1507 // wait 2s and check again before breaking it
1508 pauseExecution(2000)
1509 if ((now() - lastLockTime) > 120000 /* 2 minutes */) {
1510 log.warn "HTTP queue lock was held for more than 2 minutes, forcing release"
1511 mutex.release()
1512 // In this case we should re-run.
1513 handleRequestQueue()
1514 }
1515 }
1516 return
1517 }
1518 lastLockTime = now()
1519 Map request = requestQueue.poll()
1520 httpRequest(request.path, this.&put, request.body, request.query)
1521
1522 // Try to process more requests and release the lock since this request
1523 // should be complete.
1524 runInMillis((request.duration * 1000), "handleRequestQueue", [data: true])
1525
1526 // If there was something to run after this then set that up as well.
1527 if (request.runAfter) {
1528 runIn(request.duration, request.runAfter)
1529 }
1530 } catch(e) {
1531 maybeLogError "Failed to run HTTP queue: ${e}"
1532 mutex.release()
1533 }
1534}
1535
1536def httpRequest(String path, Closure method = this.&get, Map body = null, Map query = null, boolean alreadyTriedRequest = false) {
1537 def result = [:]
1538 if (!state.session || !state.session.key) {
1539 if (alreadyTriedRequest) {
1540 maybeLogError "Already attempted login but still no session key, giving up"
1541 return result
1542 } else {
1543 login()
1544 return httpRequest(path, method, body, query, true)
1545 }
1546 }
1547 def payload = body ? new groovy.json.JsonBuilder(body).toString() : null
1548 Map queryString = [_k: state.session.key]
1549 if (query) {
1550 queryString = queryString + query
1551 }
1552 Map statusParams = [
1553 uri: API_URL,
1554 path: path,
1555 requestContentType: "application/json",
1556 contentType: "application/json",
1557 headers: [
1558 "Host": API_HOST,
1559 "User-Agent": USER_AGENT,
1560 "Cookie": state.session?.cookies,
1561 "DNT": "1",
1562 "Accept-Version": "4.4.1",
1563 "X-App-Version": "4.4.1",
1564 ],
1565 query: queryString,
1566 body: payload,
1567 timeout: 20
1568 ]
1569 if (payload) {
1570 debug "Sending request for ${path} with query ${queryString}: ${payload}"
1571 } else {
1572 debug "Sending request for ${path} with query ${queryString}"
1573 }
1574 try {
1575 method(statusParams) { response ->
1576 if (response.success) {
1577 result = response.data
1578 } else {
1579 maybeLogError "Failed request for ${path} ${queryString} with payload ${payload}:(${response.status}) ${response.data}"
1580 state.status = "API Error"
1581 }
1582 }
1583 return result
1584 } catch (Exception e) {
1585 if (e.toString().contains("Unauthorized") && !alreadyTriedRequest) {
1586 // The session is invalid so retry login before giving up.
1587 log.info "Unauthorized, retrying login"
1588 login()
1589 return httpRequest(path, method, body, query, true)
1590 } else {
1591 // There was some other error so retry if that hasn't already been done
1592 // otherwise give up. Not Found errors won't improve with retry to don't
1593 // bother.
1594 if (!alreadyTriedRequest && !e.toString().contains("Not Found")) {
1595 maybeLogError "Retrying failed request ${statusParams}\n${e}"
1596 return httpRequest(path, method, body, query, true)
1597 } else {
1598 if (e.toString().contains("Not Found")) {
1599 // Don't bother polluting logs for Not Found errors as they are likely
1600 // either intentional (trying to figure out if outlet exists) or a code
1601 // bug. In the latter case we still want diagnostic data so we use
1602 // debug logging.
1603 debug "Error making request ${statusParams}\n${e}"
1604 return result
1605 }
1606 maybeLogError "Error making request ${statusParams}\n${e}"
1607 state.status = "API Error"
1608 return result
1609 }
1610 }
1611 }
1612}
1613
1614/**
1615 * Only logs an error message if one wasn't logged within the last
1616 * N minutes where N is configurable.
1617 */
1618void maybeLogError(String msg) {
1619 if (!settings.limitErrorLogsMin /* off */
1620 || (now() - lastErrorLogTime) > (settings.limitErrorLogsMin * 60 * 1000)) {
1621 log.error msg
1622 lastErrorLogTime = now()
1623 }
1624}
1625
1626void debug(String msg) {
1627 if (logEnable) {
1628 log.debug msg
1629 }
1630}
1631
1632// Can't seem to use method reference to built-in so
1633// we create simple ones to pass around
1634def get(Map params, Closure closure) {
1635 httpGet(params, closure)
1636}
1637
1638def put(Map params, Closure closure) {
1639 httpPut(params, closure)
1640}
1641
1642// vim: tabstop=2 shiftwidth=2 expandtab
1643
1644