4 import java.text.DecimalFormat
5 import groovy.json.JsonSlurper
7 private getApiUrl() { "https://api.netatmo.com" }
8 private getVendorAuthPath() { "${apiUrl}/oauth2/authorize?" }
9 private getVendorTokenPath(){ "${apiUrl}/oauth2/token" }
10 private getVendorIcon() { "https://s3.amazonaws.com/smartapp-icons/Partner/netamo-icon-1%402x.png" }
11 private getClientId() { appSettings.clientId }
12 private getClientSecret() { appSettings.clientSecret }
13 private getServerUrl() { appSettings.serverUrl }
14 private getShardUrl() { return getApiServerUrl() }
15 private getCallbackUrl() { "${serverUrl}/oauth/callback" }
16 private getBuildRedirectUrl() { "${serverUrl}/oauth/initialize?appId=${app.id}&access_token=${atomicState.accessToken}&apiServerUrl=${shardUrl}" }
19 name: "Netatmo (Connect)",
21 author: "Brian Steere",
22 description: "Integrate your Netatmo devices with SmartThings",
23 category: "SmartThings Labs",
24 iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/netamo-icon-1.png",
25 iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/netamo-icon-1%402x.png",
30 appSetting "clientSecret"
31 appSetting "serverUrl"
35 page(name: "Credentials", title: "Fetch OAuth2 Credentials", content: "authPage", install: false)
36 page(name: "listDevices", title: "Netatmo Devices", content: "listDevices", install: false)
40 path("/oauth/initialize") {action: [GET: "oauthInitUrl"]}
41 path("/oauth/callback") {action: [GET: "callback"]}
45 // log.debug "running authPage()"
48 def uninstallAllowed = false
49 def oauthTokenProvided = false
51 // If an access token doesn't exist, create one
52 if (!atomicState.accessToken) {
53 atomicState.accessToken = createAccessToken()
54 log.debug "Created access token"
57 if (canInstallLabs()) {
59 def redirectUrl = getBuildRedirectUrl()
60 // log.debug "Redirect url = ${redirectUrl}"
62 if (atomicState.authToken) {
63 description = "Tap 'Next' to select devices"
64 uninstallAllowed = true
65 oauthTokenProvided = true
67 description = "Tap to enter credentials"
70 if (!oauthTokenProvided) {
71 log.debug "Showing the login page"
72 return dynamicPage(name: "Credentials", title: "Authorize Connection", nextPage:"listDevices", uninstall: uninstallAllowed, install:false) {
74 paragraph "Tap below to login to Netatmo and authorize SmartThings access"
75 href url:redirectUrl, style:"embedded", required:false, title:"Connect to Netatmo", description:description
79 log.debug "Showing the devices page"
80 return dynamicPage(name: "Credentials", title: "Connected", nextPage:"listDevices", uninstall: uninstallAllowed, install:false) {
82 input(name:"Devices", style:"embedded", required:false, title:"Netatmo is connected to SmartThings", description:description)
87 def upgradeNeeded = """To use SmartThings Labs, your Hub should be completely up to date. To update your Hub, access Location Settings in the Main Menu (tap the gear next to your location name), select your Hub, and choose "Update Hub"."""
88 return dynamicPage(name:"Credentials", title:"Upgrade needed!", nextPage:"", install:false, uninstall: true) {
90 paragraph "$upgradeNeeded"
98 // log.debug "runing oauthInitUrl()"
100 atomicState.oauthInitState = UUID.randomUUID().toString()
103 response_type: "code",
104 client_id: getClientId(),
105 client_secret: getClientSecret(),
106 state: atomicState.oauthInitState,
107 redirect_uri: getCallbackUrl(),
108 scope: "read_station"
111 // log.debug "REDIRECT URL: ${getVendorAuthPath() + toQueryString(oauthParams)}"
113 redirect (location: getVendorAuthPath() + toQueryString(oauthParams))
117 // log.debug "running callback()"
119 def code = params.code
120 def oauthState = params.state
122 if (oauthState == atomicState.oauthInitState) {
125 grant_type: "authorization_code",
126 client_secret: getClientSecret(),
127 client_id : getClientId(),
129 scope: "read_station",
130 redirect_uri: getCallbackUrl()
133 // log.debug "TOKEN URL: ${getVendorTokenPath() + toQueryString(tokenParams)}"
135 def tokenUrl = getVendorTokenPath()
136 def requestTokenParams = [
138 requestContentType: 'application/x-www-form-urlencoded',
142 // log.debug "PARAMS: ${requestTokenParams}"
145 httpPost(requestTokenParams) { resp ->
146 //log.debug "Data: ${resp.data}"
147 atomicState.refreshToken = resp.data.refresh_token
148 atomicState.authToken = resp.data.access_token
149 // resp.data.expires_in is in milliseconds so we need to convert it to seconds
150 atomicState.tokenExpires = now() + (resp.data.expires_in * 1000)
153 log.debug "callback() failed: $e"
156 // If we successfully got an authToken run sucess(), else fail()
157 if (atomicState.authToken) {
164 log.error "callback() failed oauthState != atomicState.oauthInitState"
169 log.debug "OAuth flow succeeded"
172 <p>Tap 'Done' to continue</p>
174 connectionStatus(message)
178 log.debug "OAuth flow failed"
179 atomicState.authToken = null
182 <p>Tap 'Done' to return</p>
184 connectionStatus(message)
187 def connectionStatus(message, redirectUrl = null) {
188 def redirectHtml = ""
191 <meta http-equiv="refresh" content="3; url=${redirectUrl}" />
198 <meta name="viewport" content="width=device-width, initial-scale=1">
199 <title>Netatmo Connection</title>
200 <style type="text/css">
201 * { box-sizing: border-box; }
203 font-family: 'Swiss 721 W01 Thin';
204 src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.eot');
205 src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.eot?#iefix') format('embedded-opentype'),
206 url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.woff') format('woff'),
207 url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.ttf') format('truetype'),
208 url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.svg#swis721_th_btthin') format('svg');
213 font-family: 'Swiss 721 W01 Light';
214 src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.eot');
215 src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.eot?#iefix') format('embedded-opentype'),
216 url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.woff') format('woff'),
217 url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.ttf') format('truetype'),
218 url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.svg#swis721_lt_btlight') format('svg');
228 vertical-align: middle;
235 font-family: 'Swiss 721 W01 Thin';
241 font-family: 'Swiss 721 W01 Light';
246 <div class="container">
247 <img src=""" + getVendorIcon() + """ alt="Vendor icon" />
248 <img src="https://s3.amazonaws.com/smartapp-icons/Partner/support/connected-device-icn%402x.png" alt="connected device icon" />
249 <img src="https://s3.amazonaws.com/smartapp-icons/Partner/support/st-logo%402x.png" alt="SmartThings logo" />
255 render contentType: 'text/html', data: html
259 // Check if atomicState has a refresh token
260 if (atomicState.refreshToken) {
261 log.debug "running refreshToken()"
264 grant_type: "refresh_token",
265 refresh_token: atomicState.refreshToken,
266 client_secret: getClientSecret(),
267 client_id: getClientId(),
270 def tokenUrl = getVendorTokenPath()
272 def requestOauthParams = [
274 requestContentType: 'application/x-www-form-urlencoded',
278 // log.debug "PARAMS: ${requestOauthParams}"
281 httpPost(requestOauthParams) { resp ->
282 //log.debug "Data: ${resp.data}"
283 atomicState.refreshToken = resp.data.refresh_token
284 atomicState.authToken = resp.data.access_token
285 // resp.data.expires_in is in milliseconds so we need to convert it to seconds
286 atomicState.tokenExpires = now() + (resp.data.expires_in * 1000)
290 log.debug "refreshToken() failed: $e"
293 // If we didn't get an authToken
294 if (!atomicState.authToken) {
302 String toQueryString(Map m) {
303 return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&")
307 log.debug "Installed with settings: ${settings}"
312 log.debug "Updated with settings: ${settings}"
319 log.debug "Initialized with settings: ${settings}"
321 // Pull the latest device info into state
324 settings.devices.each {
326 def detail = state?.deviceDetail[deviceId]
329 switch(detail?.type) {
331 log.debug "Base station"
332 createChildDevice("Netatmo Basestation", deviceId, "${detail.type}.${deviceId}", detail.module_name)
335 log.debug "Outdoor module"
336 createChildDevice("Netatmo Outdoor Module", deviceId, "${detail.type}.${deviceId}", detail.module_name)
339 log.debug "Rain Gauge"
340 createChildDevice("Netatmo Rain", deviceId, "${detail.type}.${deviceId}", detail.module_name)
343 log.debug "Additional module"
344 createChildDevice("Netatmo Additional Module", deviceId, "${detail.type}.${deviceId}", detail.module_name)
347 } catch (Exception e) {
348 log.error "Error creating device: ${e}"
352 // Cleanup any other devices that need to go away
353 def delete = getChildDevices().findAll { !settings.devices.contains(it.deviceNetworkId) }
354 log.debug "Delete: $delete"
355 delete.each { deleteChildDevice(it.deviceNetworkId) }
357 // Run initial poll and schedule future polls
359 runEvery5Minutes("poll")
363 log.debug "Uninstalling"
364 removeChildDevices(getChildDevices())
367 def getDeviceList() {
368 if (atomicState.authToken) {
370 log.debug "Getting stations data"
373 state.deviceDetail = [:]
374 state.deviceState = [:]
376 apiGet("/api/getstationsdata") { resp ->
377 resp.data.body.devices.each { value ->
379 deviceList[key] = "${value.station_name}: ${value.module_name}"
380 state.deviceDetail[key] = value
381 state.deviceState[key] = value.dashboard_data
382 value.modules.each { value2 ->
383 def key2 = value2._id
384 deviceList[key2] = "${value.station_name}: ${value2.module_name}"
385 state.deviceDetail[key2] = value2
386 state.deviceState[key2] = value2.dashboard_data
391 return deviceList.sort() { it.value.toLowerCase() }
398 private removeChildDevices(delete) {
399 log.debug "Removing ${delete.size()} devices"
401 deleteChildDevice(it.deviceNetworkId)
405 def createChildDevice(deviceFile, dni, name, label) {
407 def existingDevice = getChildDevice(dni)
408 if(!existingDevice) {
409 log.debug "Creating child"
410 def childDevice = addChildDevice("dianoga", deviceFile, dni, null, [name: name, label: label, completedSetup: true])
412 log.debug "Device $dni already exists"
415 log.error "Error creating device: ${e}"
420 log.debug "Listing devices"
422 def devices = getDeviceList()
424 dynamicPage(name: "listDevices", title: "Choose Devices", install: true) {
426 input "devices", "enum", title: "Select Devices", required: false, multiple: true, options: devices
429 section("Preferences") {
430 input "rainUnits", "enum", title: "Rain Units", description: "Millimeters (mm) or Inches (in)", required: true, options: [mm:'Millimeters', in:'Inches']
435 def apiGet(String path, Map query, Closure callback) {
436 log.debug "running apiGet()"
438 // If the current time is over the expiration time, request a new token
439 if(now() >= atomicState.tokenExpires) {
440 atomicState.authToken = null
445 access_token: atomicState.authToken
454 // log.debug "apiGet(): $apiGetParams"
457 httpGet(apiGetParams) { resp ->
461 log.debug "apiGet() failed: $e"
462 // Netatmo API has rate limits so a failure here doesn't necessarily mean our token has expired, but we will check anyways
463 if(now() >= atomicState.tokenExpires) {
464 atomicState.authToken = null
470 def apiGet(String path, Closure callback) {
471 apiGet(path, [:], callback);
475 log.debug "Polling..."
479 def children = getChildDevices()
480 //log.debug "State: ${state.deviceState}"
482 settings.devices.each { deviceId ->
483 def detail = state?.deviceDetail[deviceId]
484 def data = state?.deviceState[deviceId]
485 def child = children?.find { it.deviceNetworkId == deviceId }
487 log.debug "Update: $child";
488 switch(detail?.type) {
490 log.debug "Updating NAMain $data"
491 child?.sendEvent(name: 'temperature', value: cToPref(data['Temperature']) as float, unit: getTemperatureScale())
492 child?.sendEvent(name: 'carbonDioxide', value: data['CO2'])
493 child?.sendEvent(name: 'humidity', value: data['Humidity'])
494 child?.sendEvent(name: 'pressure', value: data['Pressure'])
495 child?.sendEvent(name: 'noise', value: data['Noise'])
498 log.debug "Updating NAModule1 $data"
499 child?.sendEvent(name: 'temperature', value: cToPref(data['Temperature']) as float, unit: getTemperatureScale())
500 child?.sendEvent(name: 'humidity', value: data['Humidity'])
503 log.debug "Updating NAModule3 $data"
504 child?.sendEvent(name: 'rain', value: rainToPref(data['Rain']) as float, unit: settings.rainUnits)
505 child?.sendEvent(name: 'rainSumHour', value: rainToPref(data['sum_rain_1']) as float, unit: settings.rainUnits)
506 child?.sendEvent(name: 'rainSumDay', value: rainToPref(data['sum_rain_24']) as float, unit: settings.rainUnits)
507 child?.sendEvent(name: 'units', value: settings.rainUnits)
510 log.debug "Updating NAModule4 $data"
511 child?.sendEvent(name: 'temperature', value: cToPref(data['Temperature']) as float, unit: getTemperatureScale())
512 child?.sendEvent(name: 'carbonDioxide', value: data['CO2'])
513 child?.sendEvent(name: 'humidity', value: data['Humidity'])
520 if(getTemperatureScale() == 'C') {
523 return temp * 1.8 + 32
527 def rainToPref(rain) {
528 if(settings.rainUnits == 'mm') {
531 return rain * 0.039370
535 def debugEvent(message, displayEvent) {
538 descriptionText: message,
539 displayed: displayEvent
541 log.debug "Generating AppDebug Event: ${results}"
545 private Boolean canInstallLabs() {
546 return hasAllHubsOver("000.011.00603")
549 private Boolean hasAllHubsOver(String desiredFirmware) {
550 return realHubFirmwareVersions.every { fw -> fw >= desiredFirmware }
553 private List getRealHubFirmwareVersions() {
554 return location.hubs*.firmwareVersionString.findAll { it }