/** * Copyright 2026 Bloodtick Jones * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except * in compliance with the License. You may obtain a copy of the License at: * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License * for the specific language governing permissions and limitations under the License. * * Bose Soundbar Integration * * Thanks to: 'cavefire' and the https://github.com/cavefire/pybose project * https://github.com/cavefire/Bose-Homeassistant * * Author: bloodtick * Date: 2026-01-04 */ public static String version() { return "1.0.00" } public static String copyright() { return "© 2026 ${author()}" } public static String author() { return "Bloodtick Jones" } import groovy.json.JsonOutput import groovy.transform.Field import java.util.Random import java.util.regex.Pattern import java.util.regex.Matcher import java.security.MessageDigest import java.net.URI import java.net.URLDecoder import groovyx.net.http.HttpResponseException @Field static final String BOSE_API_KEY = "67616C617061676F732D70726F642D6D61647269642D696F73" // Azure B2C config from BoseAuth.py @Field static final String AZ_BASE_URL = "https://myboseid.bose.com" @Field static final String AZ_TENANT = "boseprodb2c.onmicrosoft.com" @Field static final String AZ_POLICY = "B2C_1A_MBI_SUSI" @Field static final String AZ_CLIENT_ID = "e284648d-3009-47eb-8e74-670c5330ae54" @Field static final String AZ_REDIRECT_URI = "bosemusic://auth/callback" @Field static final String AZ_UI_LOCALES = "de-de" // Bose exchange endpoint from BoseAuth.py @Field static final String BOSE_EXCHANGE_URL = "https://id.api.bose.io/id-jwt-core/idps/aad/B2C_1A_MBI_SUSI/token" // Application specific constants @Field static final String sColorDarkBlue = "#1A77C9" @Field static final String sColorLightGrey = "#DDDDDD" @Field static final String sColorDarkGrey = "#696969" @Field static final String sColorDarkRed = "DarkRed" @Field static final String sColorYellow = "#8B8000" @Field static final String sDriverName = "Bose Soundbar Device" definition( name: "Bose Soundbar Integration", namespace: "bloodtick", author: "Hubitat", description: "Connect your Bose Soundbar Speakers (not SoundTouch) to Hubitat.", category: "Convenience", importUrl: "https://raw.githubusercontent.com/bloodtick/Hubitat/main/boseSoundbar/boseSoundbarIntegration.groovy", iconUrl: "", iconX2Url: "", singleInstance: false, installOnOpen: true ) preferences { page(name: "pageMain") } Map pageMain() { dynamicPage(name: "pageMain", install: true, uninstall: true) { displayHeader() section(menuHeader("Account Configuration")) { input(name:"username", type:"string", title: "Bose Username", required: true, width: 3, submitOnChange: true, ) input(name:"password", type:"password", title: "Bose Password", required: true, width: 3, submitOnChange: true, newLineAfter:true) input(name: "dynamic::initialize", type: "button", title: "Initialize", width: 3, style:"width:50%;") input(name: "dynamic::refresh", type: "button", title: "Refresh", width: 3, style:"width:50%;", newLineAfter:true) paragraph( "Status: " + ssrEventSpan(name:"authorization", defaultValue:"pending", newLineAfter:true) ) paragraph( ssrEventSpan(name:"tokenStatus", newLineAfter:true) ) } section(menuHeader("Speaker Configuration")) { input(name:"speakerLabel", type:"string", title: "Speaker Display Name", required: false, width: 3, submitOnChange: true, ) input(name:"speakerIpAddress", type:"string", title: "Speaker IP Address", required: false, width: 3, submitOnChange: true, newLineAfter:true) input(name: "dynamic::createDevice", type: "button", title: "Create Device", width: 3, style:"width:50%;") input(name: "dynamic::refreshDevices", type: "button", title: "Refresh", width: 3, style:"width:50%;", newLineAfter:true) String deviceList = "" deviceList += "" getChildDevices()?.each { device -> String deviceIp = device.getSetting("deviceIp") String deviceUri = "http://${location.hub.getDataValue("localIP")}/device/edit/${device.getId()}" String displayName = "${device?.getDisplayName()} " String healthStatus = "${device.currentValue("healthStatus")?:"unknown"}" String volume = "${device.currentValue("volume")?:"unknown"}" String switch_ = "${device.currentValue("switch")?:"unknown"}" //paragraph( "$displayName : ip=$deviceIp switch=$switch_ healthStatus=$healthStatus volume=$volume
" ) deviceList += "" } deviceList +="
SpeakerIP AddressStatusSwitchVolume
$displayName$deviceIp$healthStatus$switch_$volume
" if(getChildDevices()) { paragraph( getFormat("line") ) paragraph( rawHtml: true, deviceList ) paragraph( rawHtml: true, """""") paragraph( rawHtml: true, """""") } } section(menuHeader("Application Logging")) { input(name: "appInfoDisable", type: "bool", title: "Disable info logging", required: false, defaultValue: false, submitOnChange: true) input(name: "appDebugEnable", type: "bool", title: "Enable debug logging", required: false, defaultValue: false, submitOnChange: true) //input(name: "appTraceEnable", type: "bool", title: "Enable trace logging", required: false, defaultValue: false, submitOnChange: true) paragraph( getFormat("line") ) } if(appDebugEnable || appTraceEnable) { runIn(1800, updatePageMain) } else { unschedule('updatePageMain') } } } void updatePageMain() { logInfo "${app.getLabel()} disabling debug and trace logs" app.updateSetting("appDebugEnable", false) app.updateSetting("appTraceEnable", false) } void setEventText(String name, String descriptionText=null) { if(!state?.event) state.event = [:] state.event[name] = descriptionText ?: "" sendEvent(name: name, value: now().toString(), type:"TEXT", descriptionText:descriptionText) } void setEventText(Map p) { setEventText(p.name, p?.descriptionText) } String getEventText(String name, String defaultValue = null) { return (state.event?.containsKey(name) ? state.event[name] : defaultValue ?: "") } String getEventText(Map p) { return getEventText(p.name, p?.defaultValue) } String ssrEventSpan(String name, String defaultValue, String style=null, Boolean newLineAfter=false) { return "${getEventText(name:name, defaultValue:defaultValue)}${newLineAfter?"
":""}" } String ssrEventSpan(Map p) { return ssrEventSpan(p.name, p?.defaultValue, p?.style, p?.newLineAfter) } String processServerSideRender(Map event) { event?.each{ k,v -> if(v=="null") { event[k]=null } } //lets normailize nulls, some show as text logTrace "processServerSideRender: $event" String response = event?.value ?: "" if(event?.type?.toUpperCase()?.contains("TEXT")) { response = event?.descriptionText ?: "" } return response } void appButtonHandler(String btn) { def (k, v, a) = btn.tokenize("::") switch(k) { case "dynamic": if(a) this."$v"(a); else this."$v"(); break default: logWarn "$btn not supported" } } def initialize() { if(state?.initializeRun && state.initializeRun + 30*1000 > now()) { logWarn "You need to wait at least 30 seconds between authorization attempts" setEventText(name:"authorization", descriptionText:"You need to wait at least 30 seconds between authorization attempts") return } state.initializeRun = now() setEventText(name:"authorization", descriptionText:"executing initialize version=${version()}") runIn(1,'initializeRun') } def refresh() { setEventText(name:"authorization", descriptionText:"executing refresh version=${version()}") runIn(1,'refreshRun') } private void initializeRun() { logInfo "executing initialize version=${version()}" state.remove('boseToken') state.remove('azureToken') try { Map result = doAzureAndBoseAuth() if (result?.ok) { logInfo "AUTH OK (bosePersonId=${result?.bosePersonId})" setEventText(name:"authorization", descriptionText:"Authorization Initialize Successful at ${localtime(now())}") setEventText(name:"tokenStatus", descriptionText:"Token Expires: ${localtime( state.boseToken.timestamp.toLong() + (state.boseToken.expires_in.toLong() * 1000) )}") } else { logError "AUTH FAILED (error=${result?.error ?: 'unknown'})" setEventText(name:"authorization", descriptionText:"Authorization Initialize Failed (error=${result?.error ?: 'unknown'})") setEventText(name:"tokenStatus", descriptionText:"") } } catch (Throwable t) { // Never throw out of initialize logError "initialize() exception: ${t}" } } private void refreshRun() { logInfo "executing refresh version=${version()}" try { Map result = doAzureAndBoseRefresh() if (result?.ok) { logInfo "REFRESH OK (bosePersonId=${result?.bosePersonId})" setEventText(name:"authorization", descriptionText:"Authorization Refresh Successful at ${localtime(now())}") setEventText(name:"tokenStatus", descriptionText:"Token Expires: ${localtime( state.boseToken.timestamp.toLong() + (state.boseToken.expires_in.toLong() * 1000) )}") } else { logError "REFRESH FAILED (error=${result?.error ?: 'unknown'})" setEventText(name:"authorization", descriptionText:"Authorization Refresh Failed (error=${result?.error ?: 'unknown'})") setEventText(name:"tokenStatus", descriptionText:"") } } catch (Throwable t) { logError "refresh() exception: ${t}" } } private void createDevice() { logInfo "executing create device: $speakerLabel ($speakerIpAddress)" String deviceNetworkId = "${UUID.randomUUID().toString()}" def device = getChildDevice(dnid) ?: addChildDevice( "bloodtick", sDriverName, deviceNetworkId, [name: sDriverName, label: speakerLabel, isComponent: true] ) device.updateSetting("deviceIp", [type:"string", value:speakerIpAddress]) device.updated() } private void refreshDevices() { /* noop */ } public String getAuthToken() { // refresh the token when we are at 80% lifespan. It seems to be 8 hours now, so every 6.4 hours is our targer refresh if(state.boseToken && ((state.boseToken.timestamp.toLong() + (state.boseToken.expires_in.toLong()*1000*0.8) as Long) < now())) { logInfo "refreshing expired authToken" refreshRun() } return (state?.boseToken?.access_token ?: null) } /* -------------------------- * App Format Helpers * -------------------------- */ String getFormat(type, myText="", myHyperlink="", myColor=sColorDarkBlue){ if(type == "line") return "
" if(type == "title") return "

${myText}

" if(type == "text") return "${myText}" if(type == "hyperlink") return "${myText}" if(type == "comments") return "
${myText}
" } String errorMsg(String msg) { getFormat("text", msg, null, sColorDarkRed) } String statusMsg(String msg) { getFormat("text", msg, null, sColorDarkBlue) } def displayHeader() { section (getFormat("title", "${app.getLabel()}" )) { paragraph "
Developed by: ${author()}
Current Version: v${version()} - ${copyright()}
" paragraph( getFormat("line") ) } } def displayFooter(){ section() { paragraph( getFormat("line") ) paragraph "
${getDefaultLabel()}

PayPal Logo

Please consider donating. This application took a lot of work to make.
If you find it valuable, I'd certainly appreciate it!
" } } def menuHeader(titleText){"
${titleText}
"} String localtime(currentTimeMillis) { def localDate = new Date(currentTimeMillis) // Converts to local time automatically return localDate.format("yyyy-MM-dd hh:mm:ss a", location.timeZone) } /* -------------------------- * Logging * -------------------------- */ private logInfo(msg) { if(appInfoDisable != true) { log.info "${app.getLabel()} ${msg}" } } private logDebug(msg) { if(appDebugEnable == true) { log.debug "${app.getLabel()} ${msg}" } } private logTrace(msg) { if(appTraceEnable == true) { log.trace "${app.getLabel()} ${msg}" } } private logWarn(msg) { log.warn "${app.getLabel()} ${msg}" } private logError(msg) { log.error "${app.getLabel()} ${msg}" } /* ============================================================ * MAIN AUTH FLOW (Hubitat Driver OR App Safe Code Space) * ============================================================ */ private Map doAzureAndBoseAuth() { Map cookieJar = [:] // session cookie jar just for this run cookieJarClear(cookieJar) logDebug "Starting Azure AD B2C authentication flow" Map pkce = generatePkce() String codeVerifier = pkce.verifier String codeChallenge = pkce.challenge logDebug "PKCE verifier len=${codeVerifier?.length()} challenge len=${codeChallenge?.length()}" // Step 1: GET authorize (follow redirects) String authUrl = "${AZ_BASE_URL}/${AZ_TENANT}/oauth2/v2.0/authorize" Map authParams = [ p: AZ_POLICY, response_type: "code", client_id: AZ_CLIENT_ID, scope: azureScope(), code_challenge_method: "S256", code_challenge: codeChallenge, redirect_uri: AZ_REDIRECT_URI, ui_locales: AZ_UI_LOCALES ] Map step1 = httpGetFollowRedirects("STEP1 authorize", authUrl, authParams, [:], cookieJar, 10) if ((step1?.status ?: 0) != 200) return fail("azure_authorize_get_failed", step1) String step1Body = safeBodyToString(step1?.body) String csrfToken = extractCsrf(step1Body, cookieJar) String txParam = extractTx(step1Body) ?: extractTx(step1?.finalUrl ?: "") if (!csrfToken || !txParam) { logError "Failed to extract CSRF token or tx parameter" return fail("azure_extract_csrf_tx_failed", [status: step1?.status, body: snippet(step1Body)]) } logDebug "CSRF Token: ${maskMid(csrfToken)}" logDebug "TX Parameter: ${maskMid(txParam)}" // Step 2: POST email String emailUrl = "${AZ_BASE_URL}/${AZ_TENANT}/${AZ_POLICY}/SelfAsserted" Map emailQuery = [tx: txParam, p: AZ_POLICY] Map emailForm = [request_type: "RESPONSE", email: (username ?: "")] Map emailHeaders = [ "X-CSRF-TOKEN": csrfToken, "X-Requested-With": "XMLHttpRequest", "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "Origin": AZ_BASE_URL, "Referer": (step1?.finalUrl ?: authUrl) ] Map step2 = httpPostForm("STEP2 email", emailUrl, emailQuery, emailHeaders, emailForm, cookieJar, true) if ((step2?.status ?: 0) != 200) return fail("azure_submit_email_failed", step2) // Step 3: GET confirm email page String confirmUrl = "${AZ_BASE_URL}/${AZ_TENANT}/${AZ_POLICY}/api/CombinedSigninAndSignup/confirmed" Map confirmQuery = [ rememberMe: "false", csrf_token: csrfToken, tx: txParam, p: AZ_POLICY, diags: JsonOutput.toJson([pageViewId:"test", pageId:"CombinedSigninAndSignup", trace:[]]) ] Map step3 = httpGet("STEP3 confirm email", confirmUrl, confirmQuery, [:], cookieJar, true) if ((step3?.status ?: 0) != 200) return fail("azure_confirm_email_failed", step3) String step3Body = safeBodyToString(step3?.body) // Update CSRF + TX if present csrfToken = extractCsrf(step3Body, cookieJar) ?: csrfToken txParam = extractTx(step3Body) ?: txParam // Step 4: POST password Map pwQuery = [tx: txParam, p: AZ_POLICY] Map pwForm = [readonlyEmail: (username ?: ""), password: (password ?: ""), request_type: "RESPONSE"] Map pwHeaders = [ "X-CSRF-TOKEN": csrfToken, "X-Requested-With": "XMLHttpRequest", "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "Origin": AZ_BASE_URL, "Referer": confirmUrl ] Map step4 = httpPostForm("STEP4 password", emailUrl, pwQuery, pwHeaders, pwForm, cookieJar, true) if ((step4?.status ?: 0) != 200) return fail("azure_submit_password_failed", step4) // Step 5: GET confirm password page, expect 302 with bosemusic:// callback + code String confirm2Url = "${AZ_BASE_URL}/${AZ_TENANT}/${AZ_POLICY}/api/SelfAsserted/confirmed" Map confirm2Query = [ csrf_token: csrfToken, tx: txParam, p: AZ_POLICY, diags: JsonOutput.toJson([pageViewId:"test2", pageId:"SelfAsserted", trace:[]]) ] Map step5 = httpGet("STEP5 confirm2", confirm2Url, confirm2Query, [:], cookieJar, false) if ((step5?.status ?: 0) != 302) { logError "Expected redirect, got: ${step5?.status}" return fail("azure_confirm2_expected_302_failed", step5) } String location = step5?.location ?: "" String authCode = extractCodeFromLocation(location) if (!authCode) return fail("azure_no_auth_code_in_redirect", step5) logDebug "Authorization code received len=${authCode?.length() ?: 0}" // Step 6: Exchange code for tokens String tokenUrl = "${AZ_BASE_URL}/${AZ_TENANT}/oauth2/v2.0/token" Map tokenQuery = [p: AZ_POLICY] Map tokenForm = [ client_id: AZ_CLIENT_ID, code_verifier: codeVerifier, grant_type: "authorization_code", scope: azureScope(), redirect_uri: AZ_REDIRECT_URI, code: authCode ] Map tokenHeaders = [ "Content-Type": "application/x-www-form-urlencoded", "Origin": "https://www.bose.de", "Referer": "https://www.bose.de/" ] Map step6 = httpPostForm("STEP6 token exchange", tokenUrl, tokenQuery, tokenHeaders, tokenForm, cookieJar, false) if ((step6?.status ?: 0) != 200) return fail("azure_token_exchange_failed", step6) Map azureTokens = step6?.body azureTokens.timestamp = now() String azureIdToken = azureTokens?.id_token String azureRefreshToken = azureTokens?.refresh_token logDebug "Azure tokens id_token len=${azureIdToken?.length() ?: 0} refresh_token len=${azureRefreshToken?.length() ?: 0}" // Step 7: Exchange Azure id_token for Bose tokens (match BoseAuth.py headers/payload) if (!azureIdToken) return fail("azure_missing_id_token", azureTokens) Map bosePayload = new LinkedHashMap() bosePayload.put("grant_type", "id_token") bosePayload.put("id_token", azureIdToken) bosePayload.put("client_id", AZ_CLIENT_ID) bosePayload.put("scope", azureScope()) Map boseHeaders = [ "Content-Type": "application/json", "X-ApiKey": BOSE_API_KEY, "X-Api-Version": "1", "X-Software-Version": "1", "X-Library-Version": "1", "User-Agent": "Bose/37362 CFNetwork/3860.200.71 Darwin/25.1.0", "Accept": "*/*", "Accept-Language": "en-US,en;q=0.9", "Accept-Encoding": "gzip, deflate, br" ] Map step7 = httpPostJson("STEP7 bose exchange", BOSE_EXCHANGE_URL, [:], boseHeaders, bosePayload, cookieJar, true) if (!((step7?.status ?: 0) in [200, 201])) { logError "Bose token exchange failed: ${step7?.status ?: 0}" logError "STEP7 bodySnippet=${snippet(safeBodyToString(step7?.body))}" return fail("bose_exchange_failed", step7) } Map boseTokens = step7?.body boseTokens.timestamp = now() String boseAccessToken = boseTokens?.access_token String boseRefreshToken = boseTokens?.refresh_token String bosePersonID = boseTokens?.bosePersonID logDebug "Bose tokens access_token len=${boseAccessToken?.length() ?: 0} refresh_token len=${boseRefreshToken?.length() ?: 0} bosePersonID present=${!!bosePersonID}" // store for later use state.boseToken = boseTokens state.azureToken = azureTokens return [ok:true, bosePersonId: bosePersonID] } private Map doAzureAndBoseRefresh() { String azureRefreshToken = null try { azureRefreshToken = state?.azureToken?.refresh_token } catch (Throwable ignored) { azureRefreshToken = null } if (!azureRefreshToken) return fail("azure_missing_refresh_token", [have:false]) String refreshUrl = "${AZ_BASE_URL}/${AZ_TENANT}/${AZ_POLICY}/oauth2/v2.0/token" Map refreshHeaders = [ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "User-Agent": "Bose/37362 CFNetwork/3860.200.71 Darwin/25.1.0", "Accept": "*/*", "Accept-Language": "en-US,en;q=0.9", "Accept-Encoding": "gzip, deflate, br", "Pragma": "no-cache", "Cache-Control": "no-cache" ] Map refreshForm = [ "refresh_token": azureRefreshToken, "client_id": AZ_CLIENT_ID, "grant_type": "refresh_token" ] Map cookieJar = [:] cookieJarClear(cookieJar) Map r1 = httpPostForm("REFRESH1 azure token refresh", refreshUrl, [:], refreshHeaders, refreshForm, cookieJar, true) if ((r1?.status ?: 0) != 200) return fail("azure_refresh_failed", r1) Map azureTokens = r1?.body azureTokens.timestamp = now() String azureIdToken = azureTokens?.id_token String newAzureRefreshToken = azureTokens?.refresh_token if (newAzureRefreshToken) azureRefreshToken = newAzureRefreshToken if (!azureIdToken) return fail("azure_missing_id_token_after_refresh", azureTokens) Map bosePayload = new LinkedHashMap() bosePayload.put("grant_type", "id_token") bosePayload.put("id_token", azureIdToken) bosePayload.put("client_id", AZ_CLIENT_ID) bosePayload.put("scope", azureScope()) Map boseHeaders = [ "Content-Type": "application/json", "X-ApiKey": BOSE_API_KEY, "X-Api-Version": "1", "X-Software-Version": "1", "X-Library-Version": "1", "User-Agent": "Bose/37362 CFNetwork/3860.200.71 Darwin/25.1.0", "Accept": "*/*", "Accept-Language": "en-US,en;q=0.9", "Accept-Encoding": "gzip, deflate, br" ] Map r2 = httpPostJson("REFRESH2 bose exchange", BOSE_EXCHANGE_URL, [:], boseHeaders, bosePayload, cookieJar, true) if (!((r2?.status ?: 0) in [200, 201])) { logError "Bose token exchange failed: ${r2?.status ?: 0}" logError "REFRESH2 bodySnippet=${snippet(safeBodyToString(r2?.body))}" return fail("bose_exchange_failed", r2) } Map boseTokens = r2?.body boseTokens.timestamp = now() String bosePersonID = boseTokens?.bosePersonID // store for later use state.boseToken = boseTokens state.azureToken = azureTokens return [ok:true, bosePersonId: bosePersonID] } /* ============================================================ * HTTP HELPERS (cookie jar + verbose logging) * ============================================================ */ private Map httpGet(String label, String url, Map query, Map headers, Map cookieJar, boolean followRedirects) { logInfo "HTTP GET ${label}" String cookieOut = cookieHeader(cookieJar) Map params = [ uri: url, query: (query ?: [:]), headers: buildHeaders(headers, cookieOut), contentType: "text/plain", requestContentType: "application/x-www-form-urlencoded", followRedirects: followRedirects ] Map out = [status:0, location:null, contentType:null, body:null] try { httpGet(params) { resp -> out.status = resp?.status ?: 0 out.contentType = resp?.contentType out.location = headerValue(resp, "Location") out.body = resp?.data updateCookieJarFromResponse(resp, cookieJar) logDebug "HTTP GET ${label} <- status=${out.status}" logDebug "contentType=${out.contentType}" } } catch (HttpResponseException e) { out.status = safeStatusCode(e) out.body = safeErrorBody(e) out.location = safeErrorHeader(e, "Location") updateCookieJarFromException(e, cookieJar) logWarn "HTTP GET ${label} exception: status code: ${out.status}, reason phrase: ${e?.message}" } catch (Throwable t) { out.status = 0 out.body = "${t}" logError "HTTP GET ${label} exception: ${t}" } return out } private Map httpGetFollowRedirects(String label, String url, Map query, Map headers, Map cookieJar, int maxHops) { String currentUrl = url Map currentQuery = (query ?: [:]) String finalUrl = currentUrl int hops = 0 Map last = null while (hops <= maxHops) { last = httpGet("${label}${hops==0?'':' (hop '+hops+')'}", currentUrl, currentQuery, headers, cookieJar, false) finalUrl = currentUrl int st = (last?.status ?: 0) as int String loc = last?.location if (st in [301,302,303,307,308] && loc) { logDebug "redirect hop=${hops} status=${st} -> ${loc}" currentUrl = absolutizeUrl(currentUrl, loc) currentQuery = [:] // after redirect, Location already contains query if needed hops++ continue } break } if (last == null) last = [status:0] last.finalUrl = finalUrl return last } private Map httpPostForm(String label, String url, Map query, Map headers, Map form, Map cookieJar, boolean followRedirects) { logInfo "HTTP POST FORM ${label}" String cookieOut = cookieHeader(cookieJar) Map params = [ uri: url, query: (query ?: [:]), headers: buildHeaders(headers, cookieOut), requestContentType: "application/x-www-form-urlencoded", //contentType: "text/plain", body: (form ?: [:]), followRedirects: followRedirects ] Map out = [status:0, location:null, contentType:null, body:null] try { httpPost(params) { resp -> out.status = resp?.status ?: 0 out.contentType = resp?.contentType out.location = headerValue(resp, "Location") out.body = resp?.data updateCookieJarFromResponse(resp, cookieJar) logDebug "HTTP POST FORM ${label} <- status=${out.status}" logDebug "contentType=${out.contentType}" } } catch (HttpResponseException e) { out.status = safeStatusCode(e) out.body = safeErrorBody(e) out.location = safeErrorHeader(e, "Location") updateCookieJarFromException(e, cookieJar) logWarn "HTTP POST FORM ${label} exception: status code: ${out.status}, reason phrase: ${e?.message}" } catch (Throwable t) { out.status = 0 out.body = "${t}" logError "HTTP POST FORM ${label} exception: ${t}" } return out } private Map httpPostJson(String label, String url, Map query, Map headers, Map jsonBody, Map cookieJar, boolean followRedirects) { logInfo "HTTP POST JSON ${label}" String cookieOut = cookieHeader(cookieJar) Map params = [ uri: url, query: (query ?: [:]), headers: buildHeaders(headers, cookieOut), requestContentType: "application/json", contentType: "application/json", body: (jsonBody ?: [:]), followRedirects: followRedirects ] Map out = [status:0, location:null, contentType:null, body:null] try { httpPost(params) { resp -> out.status = resp?.status ?: 0 out.contentType = resp?.contentType out.location = headerValue(resp, "Location") out.body = resp?.data updateCookieJarFromResponse(resp, cookieJar) logDebug "HTTP POST JSON ${label} <- status=${out.status}" logDebug "contentType=${out.contentType}" } } catch (HttpResponseException e) { out.status = safeStatusCode(e) out.body = safeErrorBody(e) out.location = safeErrorHeader(e, "Location") updateCookieJarFromException(e, cookieJar) logWarn "HTTP POST JSON ${label} exception: status code: ${out.status}, reason phrase: ${e?.message}" } catch (Throwable t) { out.status = 0 out.body = "${t}" logError "HTTP POST JSON ${label} exception: ${t}" } return out } /* ============================================================ * COOKIE JAR * ============================================================ */ private void cookieJarClear(Map jar) { jar?.clear() } private void updateCookieJarFromResponse(resp, Map jar) { try { List setCookies = headerValues(resp, "Set-Cookie") if (setCookies && jar != null) { setCookies.each { String sc -> Map kv = parseSetCookie(sc) if (kv?.k) jar[kv.k] = kv.v } } } catch (Throwable t) { logWarn "cookie update exception: ${t}" } } private void updateCookieJarFromException(HttpResponseException e, Map jar) { try { def resp = e?.response if (resp) updateCookieJarFromResponse(resp, jar) } catch (Throwable t) { /* ignore */ } } private Map parseSetCookie(String setCookieValue) { if (!setCookieValue) return null String first = setCookieValue.split(";")[0] int idx = first.indexOf("=") if (idx <= 0) return null String k = first.substring(0, idx) String v = first.substring(idx + 1) return [k:k, v:v] } private String cookieHeader(Map jar) { if (!jar || jar.isEmpty()) return null List parts = [] jar.each { k, v -> if (k != null && v != null) parts << "${k}=${v}" } return parts.join("; ") } /* ============================================================ * EXTRACTION * ============================================================ */ private String extractCsrf(String html, Map cookieJar) { List patterns = [ 'x-ms-cpim-csrf["\\s]*[:=]["\\s]*([^";\\s]+)', '"csrf"["\\s]*:["\\s]*"([^"]+)"', 'csrf_token["\\s]*[:=]["\\s]*"?([^";\\s]+)"?', 'X-CSRF-TOKEN["\\s]*[:=]["\\s]*"?([^";\\s]+)"?' ] String token = extractByPatterns(html ?: "", patterns, true) if (token) return token // Try from cookies try { cookieJar?.each { k, v -> if ((k ?: "").toLowerCase().contains("csrf")) { logDebug "Found CSRF token in cookie: ${k}" return v } } } catch (Throwable t) { /* ignore */ } logDebug "No CSRF token found" return null } private String extractTx(String textOrUrl) { List patterns = [ '[?&]tx=([^&"\\\']+)', '"tx"["\\s]*:["\\s]*"([^"]+)"', 'StateProperties=([^&"\\\']+)' ] String tx = extractByPatterns(textOrUrl ?: "", patterns, false) if (!tx) logDebug "No tx parameter found" return tx } private String extractByPatterns(String input, List patterns, boolean ignoreCase) { if (!input) return null for (String p : patterns) { try { Pattern pat = ignoreCase ? Pattern.compile(p, Pattern.CASE_INSENSITIVE) : Pattern.compile(p) Matcher m = pat.matcher(input) if (m.find()) { logDebug "regex matched pattern=${p}" return m.group(1) } } catch (Throwable t) { /* ignore */ } } return null } private String extractCodeFromLocation(String location) { if (!location) return null try { URI u = new URI(location) Map q = parseQuery(u?.getQuery() ?: "") return q?.code } catch (Throwable t) { String s = (location ?: "") int i = s.indexOf("code=") if (i >= 0) { String tail = s.substring(i + 5) int amp = tail.indexOf("&") String code = (amp >= 0) ? tail.substring(0, amp) : tail return urlDecode(code) } } return null } private Map parseQuery(String query) { Map out = [:] if (!query) return out query.split("&").each { kv -> int idx = kv.indexOf("=") if (idx > 0) { String k = urlDecode(kv.substring(0, idx)) String v = urlDecode(kv.substring(idx + 1)) out[k] = v } } return out } /* ============================================================ * PKCE * ============================================================ */ private Map generatePkce() { byte[] bytes = new byte[32] new Random().nextBytes(bytes) String verifier = base64UrlNoPad(bytes) byte[] digest = MessageDigest.getInstance("SHA-256").digest(verifier.getBytes("UTF-8")) String challenge = base64UrlNoPad(digest) return [verifier: verifier, challenge: challenge] } private String base64UrlNoPad(byte[] b) { String s = b.encodeBase64().toString() s = s.replace("+", "-").replace("/", "_").replace("=", "") return s } /* ============================================================ * UTIL * ============================================================ */ private String azureScope() { return "openid email profile offline_access ${AZ_CLIENT_ID}" } private Map buildHeaders(Map headers, String cookieOut) { Map h = [:] if (headers) h.putAll(headers) if (cookieOut) h["Cookie"] = cookieOut return h } private String headerValue(resp, String name) { try { List vals = headerValues(resp, name) return vals ? (vals[0] as String) : null } catch (Throwable t) { return null } } private List headerValues(resp, String name) { List out = [] try { def hs = resp?.headers if (hs) { hs.each { h -> try { String hn = h?.name if (hn && hn.equalsIgnoreCase(name)) out << (h?.value as String) } catch (Throwable t2) { /* ignore */ } } } } catch (Throwable t) { /* ignore */ } return out } private int safeStatusCode(HttpResponseException e) { try { return (e?.statusCode ?: 0) as int } catch (Throwable t) { return 0 } } private String safeErrorBody(HttpResponseException e) { try { def data = e?.response?.data return safeBodyToString(data) } catch (Throwable t) { return "" } } private String safeErrorHeader(HttpResponseException e, String name) { try { def resp = e?.response if (resp) return headerValue(resp, name) } catch (Throwable t) { /* ignore */ } return null } private String safeBodyToString(def data) { if (data == null) return "" if (data instanceof String) return (String)data String fromReader = readAllIfReadable(data) if (fromReader != null) return fromReader try { def t = data?.text if (t != null) return t.toString() } catch (Throwable ignored) { } try { return data.toString() } catch (Throwable ignored2) { return "" } } // Hubitat-safe "duck typing" reader drain (no instanceof Reader, no ClassExpression, no getClass) private String readAllIfReadable(def obj) { try { char[] buf = new char[4096] StringBuilder sb = new StringBuilder() while (true) { def nObj = obj.read(buf) if (nObj == null) break int n = (nObj as int) if (n <= 0) { if (n == -1) break break } sb.append(buf, 0, n) } try { obj.close() } catch (Throwable ignoredClose) { } String s = sb.toString() return (s != null && s.length() > 0) ? s : null } catch (Throwable ignored) { return null } } private String snippet(String s, int maxLen=250) { if (!s) return "(empty)" String x = s x = x.replaceAll(/(?i)\"password\"\s*:\s*\"[^\"]+\"/, '"password":"*****"') x = x.replaceAll(/(?i)password=[^&\s]+/, 'password=*****') x = x.replaceAll(/(?i)\"id_token\"\s*:\s*\"[^\"]+\"/, '"id_token":"hidden"') x = x.replaceAll(/(?i)\"access_token\"\s*:\s*\"[^\"]+\"/, '"access_token":"hidden"') x = x.replaceAll(/(?i)\"refresh_token\"\s*:\s*\"[^\"]+\"/, '"refresh_token":"hidden"') if (x.length() > maxLen) return x.substring(0, maxLen) + "...(len=" + x.length() + ")" return x } private String maskMid(String s) { if (!s) return "(null)" if (s.length() <= 16) return s return s.substring(0, 10) + "..." + s.substring(s.length()-6) } private String urlDecode(String s) { try { return URLDecoder.decode(s ?: "", "UTF-8") } catch (Throwable t) { return s } } private String absolutizeUrl(String base, String loc) { try { URI b = new URI(base) URI u = new URI(loc) if (u.isAbsolute()) return loc URI r = b.resolve(u) return r.toString() } catch (Throwable t) { return loc } } private Map fail(String code, Map detail) { logError "${code}" if (detail != null) { try { // keep the same detail serialization behavior as before (used in your debug) logDebug "detail=${JsonOutput.toJson(detail)}" } catch (Throwable ignored) { logDebug "detail=(unserializable)" } } return [ok:false, error:code] }