I've just posted an update that allows you to ask the driver to retry a number of times to communicate with a device. This is because communication between the TaHoma hub and RTS devices can intermittently fail.
Has anyone able to add Zigbee v3 blinds with this integration?
@mainak.m, why not add them directly to Hubitat rather than adding them to TaHoma? It seems inefficient to add them to TaHoma and then control them via Hubitat, might as well add them directly to Hubitat and bypass TaHoma.
Thanks for this Keith. Appreciated! So I'm a little rusty, does the new code replace the code for
Tahoma Switch or for Tahoma Switch - RTS Blinds.
Also do I just delete the old code and add in the new code on the current driver or do I add this code and make it appear as a new driver?
Appreciated as ever. Belting pice of code and improved WAF beyond recognition ![]()
@stueyhughes, both code files "Tahoma Switch" and "Tahoma Switch - RTS Blinds" are part of the driver. I've only changed "Tahoma Switch" though. The easiest way I've found to update a single driver in hubitat is to go to the Drivers, click on "Tahoma Switch" and then past in the code from github for that file.
@keithw I have tried that, reset the motor completely and managed to add it to Hubitat directly. None of the shade drivers worked, hence was curious about this integration.
Hey @keithw - I downloaded and installed your drivers today as I just (yesterday) got a Somfy TaHoma Switch for a new house we moved into recently. The house already had an RTS device, and we have bought (but not yet installed) some somfy curtain (zigbee control) rods - and I wanted to be able to control everything. I followed the instructions in the readme and I was able to add the Switch, and got the rest of the data populated - and it seems that Hubitat can see my only RTS device (all that's installed at present) called "Ethan's Outdoor Screen" - but it has an error adding it:
dev:2762025-01-16 10:28:53.438 PMdebugSomfy TaHoma Hub unknown device internal:PodV3Component
dev:2762025-01-16 10:28:53.436 PMdebugSomfy TaHoma Hub error adding RTS device Ethan’s Outside Screen com.hubitat.app.exception.UnknownDeviceTypeException: Device type 'TaHoma Component' in namespace 'bitlush' not found
dev:2762025-01-16 10:28:53.432 PMdebugSomfy TaHoma Hub RTS Component Ethan’s Outside Screen
dev:2762025-01-16 10:28:53.431 PMdebugSomfy TaHoma Hub unknown device zigbee:TransceiverV3_0Component
dev:2762025-01-16 10:28:53.429 PMdebugSomfy TaHoma Hub unknown device internal:WifiComponent
Thoughts?
Thanks in advance for the work!
Keith is a superstar and will definitely help you out. I'm surprised there's not much more uptake on Keith's work. Presumably people with Somfy/Tahoma blinds don't use Hubitat. One point if note folks. Because the status of the blinds doesn't report back to HE, then HE doesn't know the status when your wife opens the blinds with the Somfy remote control or APP. To counteract this, I've placed a smartthings multisensor on each blind to determine whether the blind is open or closed with the XYZ axis.
@ user2506, it should be fixed now
PERFECT - works great! Thanks Keith! I'm also going to have some curtain motors with zigbee radios - I know this was designed for RTS motors, but do you think it would be difficult to add the zigbee control motors also? I know it seems superfluous, but I have wall control panels (amazon fire tablets) that use actiontiles/hubitat for in-room controls, but I wanted the positional feedback and the option to use Homekit for voice controls. Anyway - just curious if it would be an easy do...and if you need someone to debug :). I'm your guy!
Thanks again for the work!
Zach
Hi All,
I just got a Tahoma, I am based in the US, I activated developer mode, but trying to do the API authentication is not working following the instructions here: GitHub - Somfy-Developer/Somfy-TaHoma-Developer-Mode: A collection of requests to use a local API with Somfy TaHoma gateways anybody has tips on how to get pass this?
Hello Keith,
I recently moved to an apartment with Somfy Zigbee blinds and would just like to have Hubitat fit in nicely in the existing ecosystem to add automation on top of the remotes that are already installed.
I was able to use your driver to connect to the TaHoma switch but, as expected, the zigbee devices are not recognized. I'm happy to help with the development and testing of updates to the driver.
Also, I saw that smartThings recently added support to the Somfy TaHoma switch, I'm not sure where to get access to these drivers but I'm sure it could help this project.
Thank you for your existing contribution on this project and I look forward to hearing from you.
If anyone is interested, I modified Keith's driver to support Zigbee blinds.
The list of supported functionalities are:
- Open
- Close
- SetPosition
- Get closureState
- Get battery level
Not the prettiest implementation but it works ![]()
Here is the TaHoma switch updated driver:
import groovy.transform.Field
import java.net.URLEncoder
import groovy.json.JsonBuilder
@Field String VERSION = "1.0.0"
@Field List<String> LOG_LEVELS = ["error", "warn", "info", "debug", "trace"]
@Field String DEFAULT_LOG_LEVEL = LOG_LEVELS[4]
@Field static retryLock = new Object()
@Field static retryQueue = [:]
metadata {
definition (name: "TaHoma Switch", namespace: "bitlush", author: "Keith Wood") {
capability "Configuration"
capability "Initialize"
command "clearDevices"
command "register"
command "refreshDevices"
//command "testIntegration"
}
preferences {
section {
input name: "tahomaUsername", type: "text", title: "Username", required: true
input name: "tahomaPassword", type: "password", title: "Password", required: true
input name: "tahomaPin", type: "password", title: "PIN", required: true
input name: "tahomaHost", type: "text", title: "Host", required: false
input name: "tahomaRetryCount", title: "Retries", type: "enum", options: [[0: "Don't retry"], [1: "1"], [2: "2"], [3: "3"]], defaultValue: 0, required: true
input name: "tahomaRegion", title: "Region", type: "enum", options: [[1: "Europe, Middle East and Africa"], [2: "Asia and Pacific"], [4: "North America"]], defaultValue: 1, required: true
input name: "logLevel", title: "Log Level", type: "enum", options: LOG_LEVELS, defaultValue: DEFAULT_LOG_LEVEL, required: false
input name: "stateCheckIntervalMinutes", title: "State Check Interval", type: "enum", options:[[0:"Disabled"], [30:"30min"], [60:"1h"], [120:"2h"], [180:"3h"], [240:"4h"], [360:"6h"], [480:"8h"], [720: "12h"]], defaultValue: 720, required: true
}
}
}
def configure() {
logMessage("debug", "configure()")
scheduleTokenCheck()
}
def clearDevices() {
logMessage("debug", "ClearState() - Clearing device states")
childDevices?.each{ deleteChildDevice(it.deviceNetworkId) }
}
def scheduleTokenCheck() {
def intervalMinutes = 120
if (stateCheckIntervalMinutes) {
intervalMinutes = stateCheckIntervalMinutes.toInteger()
}
if (intervalMinutes) {
if (intervalMinutes < 60) {
schedule("0 */${intervalMinutes} * ? * *", checkState)
}
else {
def intervalHours = intervalMinutes / 60
schedule("0 0 */${intervalHours} ? * *", checkState)
}
}
}
def checkState() {
reregister()
}
def apiInvoke(path, body) {
def params = [
uri: tahomaSwitchUri(),
path: "/enduser-mobile-web/1/enduserAPI" + path,
ignoreSSLIssues: true,
headers: ["Content-Type": "application/json", "Authorization": "Bearer ${state.tokenId}"],
timeout: 30,
body: body
]
return apiInvokeSynchronized(params, path, body)
}
def apiInvokeForDevice(path, body, deviceURL, retryCount) {
def params = [
uri: tahomaSwitchUri(),
path: "/enduser-mobile-web/1/enduserAPI" + path,
ignoreSSLIssues: true,
headers: ["Content-Type": "application/json", "Authorization": "Bearer ${state.tokenId}"],
timeout: 30,
body: body
]
def responseData = apiInvokeSynchronized(params, path, body)
if (retryCount > 0) {
retryLaterForDevice(path, body, deviceURL, retryCount - 1);
}
}
def retryLaterForDevice(path, body, deviceURL, retryCount) {
synchronized (retryLock) {
retryQueue[deviceURL] = [path: path, body: body, retryCount: retryCount]
}
def retryIndex = Integer.parseInt(tahomaRetryCount) - retryCount
def retryInterval = 60 * (retryIndex)
logMessage("trace", "retry state: ${atomicState}, retry ${retryIndex} of ${tahomaRetryCount}, retry interval ${retryInterval} seconds")
runIn(retryInterval, "retry")
}
def retry(retries) {
def queue
synchronized (retryLock) {
queue = retryQueue
retryQueue = [:]
}
queue.each { entry ->
deviceURL = entry.key
path = entry.value["path"]
body = entry.value["body"]
retryCount = entry.value["retryCount"]
logMessage("trace", "retrying blinds $deviceURL")
apiInvokeForDevice(path, body, deviceURL, retryCount as Integer)
}
}
@groovy.transform.Synchronized
def apiInvokeSynchronized(params, path, body) {
def responseData = null
for (int i = 0; i < 4; i++) {
def retry = false
try {
if (body == null) {
httpGet(params) { response -> responseData = response.data }
}
else {
httpPost(params) { response -> responseData = response.data }
}
}
catch (org.apache.http.conn.ConnectTimeoutException error) {
logHttpException(error)
retry = true
params.timeout = min(180, params.timeout * 2)
}
catch (Exception error) {
logHttpException(error)
}
finally {
if (!retry) {
break
}
}
}
return responseData
}
def apiPost(path, body) {
return apiInvoke(path, body)
}
def apiGet(path) {
return apiInvoke(path, null)
}
def logHttpException(Exception error) {
if (error instanceof org.apache.http.conn.ConnectTimeoutException) {
logMessage("error", "callApi timeout token=${state.tokenId}")
}
else if (error instanceof groovyx.net.http.HttpResponseException) {
logMessage("error", "callApi response error: ${error}, r = ${error.getResponse().getData()} token=${state.tokenId}")
}
else {
logMessage("error", "callApi general error: ${error}, token=${state.tokenId}")
}
}
def tahomaSwitchUri() {
if (tahomaHost?.length() > 1) {
return "https://${tahomaHost}:8443"
}
else {
return "https://gateway-${tahomaPin}:8443"
}
}
def register(force = false) {
logMessage("debug", "generateToken()")
if (force || !state.tokenId || state.tokenId.length() < 5) {
state.uuid = UUID.randomUUID().toString()
createNewSession()
tokenId = generateToken()
token = activateToken(tokenId)
state.tokenId = tokenId
}
else {
logMessage("debug", "already registered and got token ${state.tokenId}")
reregister()
}
}
/*def testIntegration() {
reregister()
}*/
def getRemainingTokenTime(token) {
def now = new Date()
def expires = now
if (token.expirationDate) {
expires = new Date(token.expirationTime)
}
return groovy.time.TimeCategory.minus(expires, new Date())
}
def durationToDays(duration) {
return duration.years * 365 + duration.days
}
def durationToHours(duration) {
return duration.years * 365 * 24 + duration.days * 24 + duration.hours
}
def reregister() {
createNewSession()
def token = getExistingToken()
if (token) {
def remaining = getRemainingTokenTime(token)
if (durationToDays(remaining) < 7) {
token = null
logMessage("info", "TaHoma Switch token will be refreshed")
}
}
if (!token) {
register(true)
}
}
def refreshDevices() {
register()
def devices = apiGet("/setup/devices")
logMessage("trace", "devices ${devices}")
def orphaned = [:]
childDevices?.each {
orphaned[it.getDataValue("deviceUrl")] = it.deviceNetworkId
}
devices.each() {
def typeName = it.controllableName;
def label = it.label
orphaned.remove(it.deviceURL)
if (typeName.startsWith("rts:") && typeName.endsWith("RTSComponent")) {
logMessage("debug", "RTS Component ${label}")
try {
addTaHomaComponent(it, "rts")
}
catch (error) {
logMessage("error", "error adding RTS device ${label} ${error}")
}
}
else if (typeName.startsWith("io:") && typeName.endsWith("IOComponent")) {
logMessage("debug", "IO Component ${label}")
try {
addTaHomaComponent(it, "io")
}
catch (error) {
logMessage("error", "error adding IO device ${label} ${error}")
}
}
else if (typeName.startsWith("zigbee:") && typeName.endsWith("RollerShadeComponent")) {
logMessage("debug", "zigbee Component ${label}")
try {
addTaHomaZigbeeComponent(it)
}
catch (error) {
logMessage("error", "error adding zigbee device ${label} ${error}")
}
}
else {
logMessage("debug", "unknown device ${typeName}")
}
}
for (orphan in orphaned) {
deleteChildDevice(orphan.value)
}
}
void addTaHomaZigbeeComponent(data) {
def name = data.label
def child = addChildDevice("bitlush", "TaHoma Switch - Zigbee Blind", device.deviceNetworkId + "-" + data.deviceURL, [name: "${name}", label: "${name}", isComponent: true])
child.updateDataValue("deviceUrl", data.deviceURL)
}
void addTaHomaComponent(data, type) {
def name = data.label
def child = addChildDevice("bitlush", "TaHoma Switch - RTS Blind", device.deviceNetworkId + "-" + data.deviceURL, [name: "${name}", label: "${name}", isComponent: true])
child.updateDataValue("deviceUrl", data.deviceURL)
}
void rtsBlindCommand(command, deviceUrl) {
apiInvokeForDevice("/exec/apply", '{ "label": "", "actions": [ { "commands": [{ "type": "ACTUATOR", "name": "' + command + '", "parameters": [] }], "deviceURL": "' + deviceUrl + '" } ] }', deviceUrl, tahomaRetryCount as Integer)
}
void zigbeeBlindCommand(command, parameters, deviceUrl) {
apiInvokeForDevice("/exec/apply", '{ "label": "", "actions": [ { "commands": [{ "type": "ACTUATOR", "name": "' + command + '", "parameters": ' + parameters + ' }], "deviceURL": "' + deviceUrl + '" } ] }', deviceUrl, tahomaRetryCount as Integer)
}
def zigbeeGet(command) {
response = apiInvoke(command, null)
logMessage("trace", "zigbeeGet Response: ${response}")
return response
}
def zigbeeGetDeviceAttributes(deviceUrl) {
devices = apiInvoke("/setup/devices", null)
attributes = devices.find {it.deviceURL == deviceUrl}
//logMessage("trace", "zigbeeGetDeviceAttributes deviceUrl: ${deviceUrl} | attributes: ${attributes}")
return attributes
}
private getExistingToken() {
def tokens = getAvailableTokens()
def token = null
tokens.each() {
if (it.label == getTokenLabel()) {
if (token) {
if (getRemainingTokenTime(token) < getRemainingTokenTime(it)) {
token = it
}
}
else {
token = it
}
}
}
return token
}
private getAvailableTokens() {
def params = [
uri: getOverkizUrl(),
path: "/enduser-mobile-web/enduserAPI/config/${tahomaPin}/local/tokens/devmode",
headers: ["Content-Type": "application/json", "Cookie": "JSESSIONID=${state.sessionId}"]
]
try {
httpGet(params) { response ->
logMessage("debug", "getAvailableTokens: ${response.data}")
return response.data;
}
}
catch (error) {
logMessage("error", "generateToken error: ${error}, r = ${error.getResponse().getData()} JSESSIONID=${state.sessionId}")
}
}
private generateToken() {
def params = [
uri: getOverkizUrl(),
path: "/enduser-mobile-web/enduserAPI/config/${tahomaPin}/local/tokens/generate",
headers: ["Content-Type": "application/json", "Cookie": "JSESSIONID=${state.sessionId}"]
]
try {
httpGet(params) { response ->
logMessage("debug", "generateToken: ${response.data}")
def data = response.data
(_, tokenId) = (data =~ /\[token:([0-9a-f]*)\]/)[0]
logMessage("debug", "tokenId: ${tokenId}")
return tokenId
}
}
catch (error) {
logMessage("error", "generateToken error: ${error} JSESSIONID=${state.sessionId}")
}
}
private getOverkizUrl() {
return "https://ha" + tahomaRegion + "01-1.overkiz.com";
}
private deleteToken(id) {
def params = [
uri: getOverkizUrl(),
path: "/enduser-mobile-web/enduserAPI/config/${tahomaPin}/local/tokens/${id}",
headers: ["Content-Type": "application/json", "Cookie": "JSESSIONID=${state.sessionId}"]
]
try {
httpDelete(params) { response ->
logMessage("debug", "deleteToken: ${response.data}")
}
}
catch (error) {
logMessage("error", "generateToken error: ${error}, r = ${error.getResponse()} JSESSIONID=${state.sessionId}")
}
}
def getTokenLabel() {
return "Hubitat:" + state.uuid;
}
private activateToken(tokenId) {
def params = [
uri: getOverkizUrl(),
path: "/enduser-mobile-web/enduserAPI/config/${tahomaPin}/local/tokens",
headers: ["Content-Type": "application/json", "Cookie": "JSESSIONID=${state.sessionId}"],
body: '{ "label": "' + getTokenLabel() + '", "token": "' + tokenId + '", "scope": "devmode" }'
]
try {
httpPost(params) { response ->
logMessage("debug", "activateToken: ${response.data}")
return response.data
}
}
catch (error) {
logMessage("error", "activateToken error: ${error} ${params.body}")
}
}
private createNewSession() {
def params = [
uri: getOverkizUrl(),
path: "/enduser-mobile-web/enduserAPI/login",
headers: ["Content-Type": "application/x-www-form-urlencoded"],
body: "userId=" + URLEncoder.encode(tahomaUsername, "UTF-8") + "&" + "userPassword=" + URLEncoder.encode(tahomaPassword, "UTF-8")
]
try {
httpPost(params) { response ->
headers = response.getHeaders()
header = headers["Set-Cookie"]
(_, sessionId) = (header =~ /JSESSIONID=([^;]*)/)[0]
logMessage("debug", "createNewSession: ${sessionId} cookie: ${header}")
state.sessionId = sessionId
}
}
catch (error) {
logMessage("error", "createNewSession error: ${error}")
}
}
def locationHandler(evt) {
logMessage("debug", "locationHandler()")
}
def installed() {
initialize()
}
def initialize() {
}
def parse(String description) {
logMessage("trace", "parse() - description: ${description.inspect()}")
def result = []
def command = zwave.parse(description, getCommandClassVersions())
if (command) {
result = zwaveEvent(command)
}
else {
logMessage("error", "parse() - Non-parsed - description: ${description?.inspect()}")
}
result
}
private logMessage(level, message) {
if (level && message) {
Integer levelIndex = LOG_LEVELS.indexOf(level)
Integer setLevelIndex = LOG_LEVELS.indexOf(logLevel)
if (setLevelIndex < 0) {
setLevelIndex = LOG_LEVELS.indexOf(DEFAULT_LOG_LEVEL)
}
if (levelIndex <= setLevelIndex) {
log."${level}" "${device.displayName} ${message}"
}
}
}
And here is the driver for the zigbee blinds:
import groovy.transform.Field
@Field String VERSION = "1.0.0"
@Field List<String> LOG_LEVELS = ["error", "warn", "info", "debug", "trace"]
@Field String DEFAULT_LOG_LEVEL = LOG_LEVELS[4]
metadata {
definition (name: "TaHoma Switch - Zigbee Blind", namespace: "bitlush", author: "Laurent de Barry") {
capability "WindowShade"
capability "Refresh"
capability "Battery"
capability "Initialize"
capability "Configuration"
attribute 'closureState', 'number'
attribute 'lastBatteryDate', 'date'
}
preferences {
section {
input name: "logLevel", title: "Log Level", type: "enum", options: LOG_LEVELS, defaultValue: DEFAULT_LOG_LEVEL, required: false
}
}
}
def Installed() {
logMessage("debug", "Zigbee blind Installed()")
refresh()
}
def configure() {
logMessage("debug", "Zigbee blind configure()")
refresh()
}
def initialize() {
logMessage("debug", "Zigbee blind initialize()")
refresh()
}
def parse(value) {
logMessage("debug","Zigbee blind parse ${value}")
}
def getDeviceUrl() {
return device.getDataValue("deviceUrl")
}
def zigbeeBlindCommand(command) {
parent.zigbeeBlindCommand(command,[], getDeviceUrl())
// Run refresh in 30 seconds to make sure that the closureState is updated after a command finishes to run
runIn(30, "refresh")
}
def zigbeeBlindCommand(command, parameters) {
parent.zigbeeBlindCommand(command,parameters, getDeviceUrl())
// Run refresh in 30 seconds to make sure that the closureState is updated after a command finishes to run
runIn(30, "refresh")
}
def zigbeeGetState(StateName, deviceUrl) {
stateValue = null
attributes = parent.zigbeeGetDeviceAttributes(deviceUrl)
if (attributes != null) {
state = attributes.states.find {it.name == "core:" + StateName}
stateValue = state?.value
}
return stateValue
}
def refresh() {
logMessage("debug", "Zigbee blind refresh()")
// The API doesn't always returns values, store it when you get it
value = zigbeeGetState("ClosureState",getDeviceUrl())
if (value != null) {
sendEvent(name: "closureState", value: value.intValue())
}
// The API doesn't always returns values, store it when you get it
value = zigbeeGetState("BatteryLevelState",getDeviceUrl())
if (value != null) {
sendEvent(name: "battery", value: value.intValue())
sendEvent(name: "lastBatteryDate", value: new Date())
}
// Runs every 5 minutes to get battery levels from the API
runIn(300, "refresh")
}
def close() {
zigbeeBlindCommand("close")
}
def open() {
zigbeeBlindCommand("open")
}
def setPosition(position) {
zigbeeBlindCommand("setPosition", [position])
}
def startPositionChange(direction) {
if (direction == "open") {
zigbeeBlindCommand("open")
}
else {
zigbeeBlindCommand("close")
}
}
def stopPositionChange() {
zigbeeBlindCommand("stop")
}
private logMessage(level, message) {
if (level && message) {
Integer levelIndex = LOG_LEVELS.indexOf(level)
Integer setLevelIndex = LOG_LEVELS.indexOf(logLevel)
if (setLevelIndex < 0) {
setLevelIndex = LOG_LEVELS.indexOf(DEFAULT_LOG_LEVEL)
}
if (levelIndex <= setLevelIndex) {
log."${level}" "${device.displayName} ${message}"
}
}
}
Hi @debarryl
Nice work, could you create a pull request for this, so I can merge back into the github repo?
Thank you!
Keith
I've updated the driver to support venetian blinds (tilt).
I'll continue testing this week and push if everything goes well. The battery levels are not updating for some reason so I have some debugging to do ![]()
I have tilt blinds and was unaware that they could report back battery level. They don't in the Somfy Tahoma App, so is this a thing? Also is there any way to report their state, open close 51% etc?
If your devices use RTS then they can only receive radio signals from the TaHoma hub. They cannot transmit information back. Only Zigbee devices can have bidirectional communication with the TaHoma hub.
Simples! Thanks Keith ![]()
My comment was specific to the zigbee blinds I have which report their battery levels. I cannot comment on the tilt blinds but if they use RTS for communication it won't be possible as this is a one way communication from the TaHoma switch to the blinds

