├── ChromecastUltra.zip ├── GoogleHomeMini.zip ├── LICENSE ├── README.md └── plugin.py /ChromecastUltra.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dnpwwo/Domoticz-Google-Plugin/d2af834e39727ae71b7ba30b2bae926a5e98cd96/ChromecastUltra.zip -------------------------------------------------------------------------------- /GoogleHomeMini.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dnpwwo/Domoticz-Google-Plugin/d2af834e39727ae71b7ba30b2bae926a5e98cd96/GoogleHomeMini.zip -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Github user Dnpwwo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Domoticz-Google Plugin 2 | Full version of Google Chromecast & Home Python Plugin for Domoticz home automation 3 | 4 | Controls multiple Google Chromecasts and Homes on your network. Tested on Linux only. 5 | 6 | ## Key Features 7 | 8 | * Devices are discovered automatically and created in the Devices tab 9 | * Voice notifications can be sent to selected Google triggered by Domoticz notifications 10 | * When network connectivity is lost the Domoticz UI will optionally show the device(s) with Red banner 11 | * Device icons are created in Domoticz 12 | * Domoticz can control the Application selected 13 | * Domoticz can control the Volume including Mute/Unmute 14 | * Domoticz can control the playing media. Play/Pause and skip forward and backwards 15 | * Google devices can be the targets of native Domoticz notifications in two different ways. Notifications are spoken in the language specified in Domoticz: 16 | * As a normal notification, these are sent to the device identified in the 'Voice Device/Group' hardware parameter 17 | * From a Domoticz event script targeting a specific device 18 | 19 | ## Installation 20 | 21 | Python version 3.7.3 or higher required & a 2019 version of Domoticz (for voice to work). On Python 3.6.x this plugin will crash Domoticz 10-20% of the time when the plugin is stopped or restarted. This appears related to a defect introduced in Python 3.6 that has been reported on the Internet. 22 | 23 | To install: 24 | * Go in your Domoticz directory using a command line. 25 | * Run: ```cd plugins``` 26 | * Run ```sudo pip3 install pychromecast``` should be version 13.0.4 or greater 27 | * Run ```sudo pip3 install gtts``` 28 | * Run: ```git clone https://github.com/dnpwwo/Domoticz-Google-Plugin.git``` 29 | * Verify that ```domoticz/plugins``` contains ```plugin.py``` and 2 icon files 30 | * Restart Domoticz. 31 | 32 | In the web UI, navigate to the Hardware page. In the hardware dropdown there will be an entry called "Google Devices - Chromecast and Home". 33 | 34 | To send voice notifications enter a Google device name in the 'Voice Device/Group' field in the hardware tab, then use the Domoticz standard Notification capability for individual Domoticz devices. Selecting notification target of 'Google_Devices' will cause the notification text to be spoken by the Google device. 35 | 36 | ## Updating 37 | 38 | To update: 39 | * Go in your Domoticz directory using a command line and open the plugins directory then the Domoticz-Google-Plugin directory. 40 | * Run: ```git pull``` 41 | * Restart Domoticz. 42 | 43 | ## Configuration 44 | 45 | ### Google Chromecast & Home Devices 46 | 47 | Nothing ! 48 | 49 | ### Domoticz 50 | 51 | | Field | Information | 52 | | ----- | ---------- | 53 | | Preferred Video/Audio Apps | Application to select when scripts request 'Video' or 'Audio' mode | 54 | | Voice message volume | Volume to play messages (previous level will be restored afterwards) | 55 | | Voice Device/Group | If specified device (or Audio Group) will receive audible notifications. The is the device's 'friendly name' as seen via the Google Home App. 'Google_Devices' will appear as a notification target when editing any Domoticz device that supports Notifications | 56 | | Time Out Lost Devices | When true, the devices in Domoitcz will have a red banner when network connectivity is lost | 57 | | Log to file | When true, messages from Google devices are written to Messages.log in the Plugin's directory | 58 | | Debug | When true the logging level will be much higher to aid with troubleshooting | 59 | 60 | ## Supported Script Commands 61 | 62 | | Command | Information | 63 | | ----- | ---------- | 64 | | On | For 'Volume' Device - Turns mute off,
For 'Playing' Device - Resume playback | 65 | | Set Volume <vol>
Set Level <level> | For 'Volume' Device - Sets volume percentage to <vol>,
For 'Playing' Device - Sets position in media to <level> percent
For Source device - Sets current Window | 66 | | Play
Playing | Resumes playing current media | 67 | | Pause
Paused | Pauses playing current media | 68 | | Rewind | Sets position in current media back to the start | 69 | | Stop
Stopped | Stops playing current media | 70 | | Trigger <URL> | Start playing <URL> | 71 | | Video | Switch device to the selected Video App | 72 | | Audio | Switch device to the selected Audio App | 73 | | Quit | Quits the current application on the device | 74 | | Off | For 'Volume' Device - Turns mute on,
For 'Playing' Device - Pause playback | 75 | | SendNotifiction | Target device speaks the message text e.g. ```commandArray['Lounge Home'] = "SendNotification Good morning"``` | 76 | 77 | ## Change log 78 | 79 | | Version | Information | 80 | | ----- | ---------- | 81 | | 1.13.1 | Bugfix: Plugin now waits for voice playback correctly | 82 | | 1.14.7 | Bugfix: Long media file now play | 83 | | 1.15.3 | Improved logging during mp3 transfer | 84 | | 1.16.13 | Bugfix: Handle groups changing 'elected leader' | 85 | | 1.18.13 | Revamped device updates + improved debugging | 86 | | 1.18.35 | Bugfix: Stopped devices being marked 'Off', fixed Playing slider | 87 | | 1.18.37 | Bugfix: Media text not showing correctly | 88 | | 1.19.5 | Removed Address & Port parameters because they seemed to confuse people. Now determined internally. | 89 | | 1.22.0 | Support newer versions of PyChromeCast where the host is not available | 90 | | 2.0.2 | Support newer versions of PyChromeCast (13.0.4) and related imports | 91 | | 2.0.3 | Bugfix: Suppress occasional TypeError in UpdatePlaying function | 92 | | 2.0.4 | Bugfix: Fix 'Model' errors during initial discovery | 93 | | 2.0.5 | Bugfix: Handle more 'None' values | 94 | -------------------------------------------------------------------------------- /plugin.py: -------------------------------------------------------------------------------- 1 | # Google Devices 2 | # 3 | # Listens for chromecast and home devices and monitors the ones it finds. 4 | # New ones are added automatically and named using their friendly name 5 | # 6 | # Author: Dnpwwo, 2019 7 | # Based on the Domoticz plugin authored by Tsjippy (https://github.com/Tsjippy) 8 | # Huge shout out to Paulus Shoutsen (https://github.com/balloob) for his pychromecast library that does all the hard work 9 | # And Fred Clift (https://github.com/minektur) who wrote the initial communication layer 10 | # Credit where it is due! 11 | # 12 | """ 13 | 14 | 15 |

Domoticz Google Plugin


16 |

Key Features

17 | 27 |

Devices

28 | 34 |

Configuration

35 | 43 |
44 | 45 | 46 | 47 | 53 | 54 | 55 | 56 | 67 | 68 | 69 | 70 | 71 | 74 | 75 | 76 | 77 | 80 | 81 | 82 | 83 | 91 | 92 | 93 |
94 | """ 95 | import Domoticz 96 | 97 | import sys,os 98 | import threading 99 | import time 100 | import json 101 | import queue 102 | import random 103 | import pychromecast 104 | import pychromecast.config as Consts 105 | try: 106 | from gtts import gTTS 107 | voiceEnabled = True 108 | except Exception as err: 109 | voiceEnabled = False 110 | voiceError = str(err) 111 | 112 | KB_TO_XMIT = 1024 * 16 113 | 114 | DEV_STATUS = "-1" 115 | DEV_VOLUME = "-2" 116 | DEV_PLAYING = "-3" 117 | DEV_SOURCE = "-4" 118 | 119 | APP_NONE=0 120 | APP_OTHER=40 121 | Apps={ 'Backdrop':Consts.APP_BACKDROP, 'Spotify':'CC32E753', 'Netflix':'CA5E8412', 'Youtube':Consts.APP_YOUTUBE, 'Other':'' } 122 | 123 | # Language overrides for when Domoticz language does not line up with Google translate 124 | # Dictionary should contain Domoticz language string as key and language string to be used e.g {"nl":"nl-NL"} 125 | langOverride = {} 126 | 127 | class GoogleDevice: 128 | def __init__(self, googleDevice): 129 | self.Name = googleDevice.name 130 | self.Model = googleDevice.model_name 131 | self.UUID = str(googleDevice.uuid) 132 | self.GoogleDevice = googleDevice 133 | self.Ready = False 134 | self.Active = False 135 | self.LogToFile("Google device created: "+str(self)) 136 | self.State = {} 137 | 138 | googleDevice.register_status_listener(self.CastStatusListener(self)) 139 | googleDevice.media_controller.register_status_listener(self.MediaStatusListener(self)) 140 | googleDevice.register_connection_listener(self.ConnectionListener(self)) 141 | googleDevice.start() 142 | 143 | class CastStatusListener: 144 | def __init__(self, parent): 145 | self.parent = parent 146 | 147 | def new_cast_status(self, status): 148 | global Apps 149 | # CastStatus(is_active_input=False, is_stand_by=True, volume_level=0.5049999952316284, volume_muted=False, app_id=None, display_name=None, namespaces=[], session_id=None, transport_id=None, status_text='') 150 | try: 151 | if (status==None): return 152 | 153 | self.parent.LogToFile(status) 154 | self.parent.Ready = True 155 | 156 | for Unit in Devices: 157 | if (Devices[Unit].DeviceID.find(self.parent.UUID+DEV_STATUS) >= 0): 158 | if (status.display_name == None) or (status.display_name == 'Backdrop'): 159 | self.Active = False 160 | nValue = 9 161 | sValue = 'Screensaver' 162 | UpdateDevice(Unit, nValue, sValue, Devices[Unit].TimedOut) 163 | else: 164 | UpdateDevice(Unit, Devices[Unit].nValue, status.display_name, Devices[Unit].TimedOut) 165 | 166 | elif (Devices[Unit].DeviceID.find(self.parent.UUID+DEV_VOLUME) >= 0): 167 | nValue = 2 168 | if (status.volume_muted == True): 169 | nValue = 0 170 | sValue = int(status.volume_level*100) 171 | UpdateDevice(Unit, nValue, str(sValue), Devices[Unit].TimedOut) 172 | 173 | elif (Devices[Unit].DeviceID.find(self.parent.UUID+DEV_SOURCE) >= 0): 174 | nValue = sValue = APP_NONE 175 | if (status.display_name != None) and (status.app_id != Consts.APP_BACKDROP): 176 | if Devices[Unit].Options['LevelNames'].find(status.display_name) == -1: 177 | nValue = sValue = len(Devices[Unit].Options['LevelNames'].split("|"))*10 178 | Devices[Unit].Options['LevelNames'] = Devices[Unit].Options['LevelNames']+"|"+status.display_name 179 | Devices[Unit].Update(nValue, str(sValue), Options=Devices[Unit].Options) 180 | 181 | # remember all apps that we see because we may need the ID again later 182 | seenApps = getConfigItem("Apps", Apps) 183 | if not status.display_name in seenApps: 184 | seenApps[status.display_name] = status.app_id 185 | setConfigItem("Apps", seenApps) 186 | else: 187 | for i, level in enumerate(Devices[Unit].Options['LevelNames'].split("|")): 188 | if level == status.display_name: 189 | nValue = sValue = i*10 190 | break 191 | 192 | UpdateDevice(Unit, nValue, str(sValue), Devices[Unit].TimedOut) 193 | 194 | except RuntimeError: # dictionary sizes can be changed mid loop 195 | pass 196 | except Exception as err: 197 | Domoticz.Error("new_cast_status: "+str(err)) 198 | exc_type, exc_obj, exc_tb = sys.exc_info() 199 | fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] 200 | Domoticz.Error(str(exc_type)+", "+fname+", Line: "+str(exc_tb.tb_lineno)) 201 | Domoticz.Error(str(status)) 202 | 203 | class MediaStatusListener: 204 | def __init__(self, parent): 205 | self.parent = parent 206 | 207 | def new_media_status(self, status): 208 | # 209 | try: 210 | if (status==None): return 211 | 212 | self.parent.LogToFile(status) 213 | self.parent.Ready = True 214 | 215 | for Unit in Devices: 216 | if (Devices[Unit].DeviceID.find(self.parent.UUID) >= 0): 217 | nValue = Devices[Unit].nValue 218 | sValue = Devices[Unit].sValue 219 | if (Devices[Unit].DeviceID.find(self.parent.UUID+DEV_STATUS) >= 0): # Overall Status 220 | liveStream = "" 221 | if status.stream_type_is_live: liveStream = "[Live] " 222 | if (status.media_is_generic): 223 | nValue = 4 224 | sValue = liveStream + stringOrBlank(status.title) 225 | elif (status.media_is_tvshow): 226 | nValue = 4 227 | sValue = liveStream+stringOrBlank(status.series_title)+"[S"+stringOrBlank(status.season)+":E"+ stringOrBlank(status.episode)+"] "+ stringOrBlank(status.title) 228 | elif (status.media_is_movie): 229 | nValue = 4 230 | sValue = liveStream + stringOrBlank(status.title) 231 | elif (status.media_is_photo): 232 | nValue = 6 233 | sValue = stringOrBlank(status.title) 234 | elif (status.media_is_musictrack): 235 | nValue = 5 236 | sValue = liveStream+stringOrBlank(status.artist)+ " ("+stringOrBlank(status.album_name)+") "+ stringOrBlank(status.title) 237 | 238 | # Check to see if we are paused 239 | if (status.player_is_paused): nValue = 2 240 | 241 | # Now tidy up and compress the string 242 | sValue = sValue.lstrip(":") 243 | sValue = sValue.rstrip(", :") 244 | sValue = sValue.replace("()", "") 245 | sValue = sValue.replace("[] ", "") 246 | sValue = sValue.replace("[S:E] ", "") 247 | sValue = sValue.replace(" ", " ") 248 | sValue = sValue.replace(", :", ":") 249 | sValue = sValue.replace(", (", " (") 250 | if (len(sValue) > 40): sValue = sValue.replace(", ", ",") 251 | if (len(sValue) > 40): sValue = sValue.replace(" (", "(") 252 | if (len(sValue) > 40): sValue = sValue.replace(") ", ")") 253 | if (len(sValue) > 40): sValue = sValue.replace(": ", ":") 254 | if (len(sValue) > 40): sValue = sValue.replace(" [", "[") 255 | if (len(sValue) > 40): sValue = sValue.replace("] ", "]") 256 | sValue = sValue.replace(",(", "(") 257 | sValue = sValue.strip() 258 | if (len(sValue) == 0): sValue = Devices[Unit].sValue 259 | UpdateDevice(Unit, nValue, str(sValue), Devices[Unit].TimedOut) 260 | 261 | elif (Devices[Unit].DeviceID.find(self.parent.UUID+DEV_PLAYING) >= 0): # Playing 262 | if (status.duration == None) or (status.current_time == None): 263 | sValue='0' 264 | else: 265 | try: 266 | sValue=str(int((status.adjusted_current_time / status.duration)*100)) 267 | except ZeroDivisionError as Err: 268 | sValue='0' 269 | except TypeError as Err: 270 | sValue='0' 271 | if (status.player_is_playing): 272 | nValue=2 273 | if (sValue=='0'): sValue='1' 274 | elif (status.player_is_paused): 275 | nValue=0 276 | if (sValue=='0'): sValue='1' 277 | else: 278 | nValue=0 279 | sValue='0' 280 | UpdateDevice(Unit, nValue, str(sValue), Devices[Unit].TimedOut) 281 | 282 | except RuntimeError: # dictionary sizes can be changed mid loop 283 | pass 284 | except Exception as err: 285 | Domoticz.Error("new_media_status: "+str(err)) 286 | exc_type, exc_obj, exc_tb = sys.exc_info() 287 | fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] 288 | Domoticz.Error(str(exc_type)+", "+fname+", Line: "+str(exc_tb.tb_lineno)) 289 | Domoticz.Error(str(status)) 290 | 291 | class ConnectionListener: 292 | def __init__(self, parent): 293 | self.parent = parent 294 | 295 | def new_connection_status(self, new_status): 296 | try: 297 | self.parent.LogToFile(new_status) 298 | Domoticz.Status(self.parent.Name+" is now: "+str(new_status)) 299 | if (new_status.status == "DISCONNECTED") or (new_status.status == "LOST") or (new_status.status == "FAILED"): 300 | self.parent.Ready = False 301 | self.parent.Active = False 302 | 303 | if (Parameters["Mode4"] != "False"): 304 | for Unit in Devices: 305 | if (Devices[Unit].DeviceID.find(self.parent.UUID) >= 0): 306 | UpdateDevice(Unit, Devices[Unit].nValue, Devices[Unit].sValue, (1,0)[new_status.status=="CONNECTED"]) 307 | 308 | except Exception as err: 309 | Domoticz.Error("new_connection_status: "+str(err)) 310 | Domoticz.Error("new_connection_status: "+str(new_status)) 311 | 312 | def LogToFile(self, status): 313 | if (Parameters["Mode5"] != "False") and (status != None): 314 | print(time.strftime('%Y-%m-%d %H:%M:%S')+" ["+self.Name+"] "+str(status), file=open(Parameters["HomeFolder"]+"Messages.log", "a")) 315 | 316 | @property 317 | def VolumeUnit(self): 318 | global DEV_VOLUME 319 | # find first device 320 | for Unit in Devices: 321 | if (Devices[Unit].DeviceID == self.UUID+DEV_VOLUME): 322 | return Unit 323 | return None 324 | 325 | @property 326 | def PlayingUnit(self): 327 | global DEV_PLAYING 328 | # find first device 329 | for Unit in Devices: 330 | if (Devices[Unit].DeviceID == self.UUID+DEV_PLAYING): 331 | return Unit 332 | return None 333 | 334 | def UpdatePlaying(self): 335 | if (self.GoogleDevice.media_controller.status != None) and (self.GoogleDevice.media_controller.status.duration != None): 336 | if (self.GoogleDevice.media_controller.status.player_is_playing): 337 | try: 338 | sValue=str(int((self.GoogleDevice.media_controller.status.adjusted_current_time / self.GoogleDevice.media_controller.status.duration)*100)) 339 | Unit = self.PlayingUnit 340 | if (Unit != None): UpdateDevice(Unit, Devices[Unit].nValue, str(sValue), Devices[Unit].TimedOut) 341 | except ZeroDivisionError as Err: 342 | pass 343 | except TypeError as Err: 344 | pass 345 | except Exception as err: 346 | Domoticz.Error("UpdatePlaying: "+str(err)) 347 | exc_type, exc_obj, exc_tb = sys.exc_info() 348 | fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] 349 | Domoticz.Error(str(exc_type)+", "+fname+", Line: "+str(exc_tb.tb_lineno)) 350 | 351 | def StoreState(self): 352 | self.State.clear() 353 | if (self.GoogleDevice.status != None): 354 | self.State['Volume'] = self.GoogleDevice.status.volume_level 355 | self.State['Muted'] = self.GoogleDevice.status.volume_muted 356 | self.State['App'] = self.GoogleDevice.app_id 357 | if (self.GoogleDevice.media_controller.status != None): 358 | self.State['SupportsSeek'] = self.GoogleDevice.media_controller.status.supports_seek 359 | 360 | self.GoogleDevice.quit_app() 361 | self.GoogleDevice.set_volume(int(Parameters["Mode3"]) / 100) 362 | self.GoogleDevice.set_volume_muted(False) 363 | 364 | def RestoreState(self): 365 | if (self.State['Volume'] != None): 366 | self.GoogleDevice.quit_app() 367 | if 'Volume' in self.State: self.GoogleDevice.set_volume(self.State['Volume']) 368 | if 'Muted' in self.State: self.GoogleDevice.set_volume_muted(self.State['Muted']) 369 | else: 370 | Domotic.Log("No device state to restore after notification") 371 | 372 | def __str__(self): 373 | return "'%s', Model: '%s', UUID: '%s'" % (self.Name, self.Model, self.UUID) 374 | 375 | class BasePlugin: 376 | 377 | def __init__(self): 378 | global voiceEnabled 379 | self.googleDevices = {} 380 | self.stopDiscovery = None 381 | self.messageServer = None 382 | self.messageQueue = None 383 | if (voiceEnabled): 384 | self.messageQueue = queue.Queue() 385 | self.messageThread = threading.Thread(name="GoogleNotify", target=BasePlugin.handleMessage, args=(self,)) 386 | 387 | def handleMessage(self): 388 | global voiceEnabled 389 | Domoticz.Debug("handleMessage: Entering notification handler") 390 | ipAddress = GetIP() 391 | ipPort = str(random.randint(10001,19999)) 392 | 393 | if (len(ipAddress) > 0): 394 | Domoticz.Log("Notifications will use IP Address: "+ipAddress+":"+ipPort+" to serve audio media.") 395 | self.messageServer = Domoticz.Connection(Name="Message Server", Transport="TCP/IP", Protocol="HTTP", Port=ipPort) 396 | self.messageServer.Listen() 397 | else: 398 | Domoticz.Error("Unable to determine host external IP address: Voice notifications will not be enabled") 399 | voiceEnabled = False 400 | 401 | while voiceEnabled: 402 | try: 403 | Message = self.messageQueue.get(block=True) 404 | if Message is None: 405 | self.messageQueue.task_done() 406 | break 407 | 408 | if (not os.path.exists(Parameters['HomeFolder']+'Messages')): 409 | os.mkdir(Parameters['HomeFolder']+'Messages') 410 | Domoticz.Debug("handleMessage: '"+Message["Text"]+"', to be sent to '"+Message["Target"]+"'") 411 | 412 | for uuid in self.googleDevices: 413 | if (self.googleDevices[uuid].GoogleDevice.name == Message["Target"]): 414 | if (self.googleDevices[uuid].Ready): 415 | language = Parameters["Language"] 416 | if (language in langOverride): language = langOverride[language] 417 | tts = gTTS(Message["Text"],lang=language) 418 | messageFileName = Parameters['HomeFolder']+'Messages/'+uuid+'.mp3' 419 | tts.save(messageFileName) 420 | if (not os.path.exists(messageFileName)): 421 | Domoticz.Error("'"+messageFileName+"' not found, translation must have failed.") 422 | break 423 | else: 424 | Domoticz.Debug("'"+messageFileName+"' created, "+str(os.path.getsize(messageFileName))+" bytes") 425 | 426 | self.googleDevices[uuid].StoreState() 427 | mc = self.googleDevices[uuid].GoogleDevice.media_controller 428 | mc.play_media("http://"+ipAddress+":"+ipPort+"/"+uuid+".mp3", 'audio/mp3') 429 | mc.block_until_active() 430 | time.sleep(1.0) 431 | endTime = time.time() + 10 432 | while (mc.status.player_is_idle) and (time.time() < endTime): 433 | Domoticz.Debug("Waiting for player (timeout in "+str(endTime - time.time())[:4]+" seconds)") 434 | time.sleep(0.5) 435 | if (mc.status.duration != None): 436 | endTime = time.time()+mc.status.duration+1 437 | while (time.time() < endTime) and (not mc.status.player_is_idle): 438 | if (mc.status.duration != None): 439 | Domoticz.Debug("Waiting for message to complete playing ("+str(mc.status.adjusted_current_time)[:4]+" of "+str(mc.status.duration)+", timeout in "+str(endTime - time.time())[:4]+" seconds)") 440 | else: 441 | Domoticz.Debug("Waiting for message to complete playing (unknown duration, timeout in "+str(endTime - time.time())[:4]+" seconds)") 442 | time.sleep(0.5) 443 | self.googleDevices[uuid].RestoreState() 444 | 445 | if (time.time() < endTime): 446 | Domoticz.Log("Notification sent to '"+Message["Target"]+"' completed") 447 | os.remove(messageFileName) 448 | else: 449 | Domoticz.Error("Notification sent to '"+Message["Target"]+"' timed out") 450 | else: 451 | Domoticz.Error("Google device '"+Message["Target"]+"' is not connected, ignored.") 452 | 453 | except Exception as err: 454 | Domoticz.Error("handleMessage: "+str(err)) 455 | exc_type, exc_obj, exc_tb = sys.exc_info() 456 | fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] 457 | Domoticz.Error(str(exc_type)+", "+fname+", Line: "+str(exc_tb.tb_lineno)) 458 | self.messageQueue.task_done() 459 | 460 | if (self.messageServer != None): self.messageServer.Disconnect() 461 | Domoticz.Debug("handleMessage: Exiting notification handler") 462 | 463 | def discoveryCallback(self, googleDevice): 464 | global DEV_STATUS,DEV_VOLUME,DEV_PLAYING,DEV_SOURCE 465 | try: 466 | uuid = str(googleDevice.uuid) 467 | if (uuid in self.googleDevices): 468 | # Happens for groups when 'elected leader' changes 469 | self.googleDevices[uuid].GoogleDevice.disconnect() 470 | self.googleDevices[uuid].GoogleDevice = None 471 | del self.googleDevices[uuid] 472 | 473 | self.googleDevices[uuid] = GoogleDevice(googleDevice) 474 | 475 | createDomoticzDevice = True 476 | maxUnitNo = 1 477 | for Device in Devices: 478 | if (Devices[Device].Unit > maxUnitNo): maxUnitNo = Devices[Device].Unit 479 | if (Devices[Device].DeviceID.find(uuid) >= 0): 480 | createDomoticzDevice = False 481 | # Check that the device name hasn't changed 482 | if (self.googleDevices[uuid].Name != Devices[Device].Name[0:len(self.googleDevices[uuid].Name)]): 483 | Domoticz.Log("Device name mismatch: '%s' vs '%s'" % (self.googleDevices[uuid].Name,Devices[Device].Name)) 484 | 485 | if (createDomoticzDevice): 486 | logoType = Parameters['Key']+'Chromecast' 487 | if (googleDevice.model_name.find("Home") >= 0) or (googleDevice.model_name == "Google Cast Group"): logoType = Parameters['Key']+'HomeMini' 488 | Domoticz.Log("Creating devices for '"+googleDevice.name+"' of type '"+googleDevice.model_name+"' in Domoticz, look in Devices tab.") 489 | Domoticz.Device(Name=self.googleDevices[uuid].Name+" Status", Unit=maxUnitNo+1, Type=17, Switchtype=17, Image=Images[logoType].ID, DeviceID=uuid+DEV_STATUS, Description=googleDevice.model_name, Used=0).Create() 490 | Domoticz.Device(Name=self.googleDevices[uuid].Name+" Volume", Unit=maxUnitNo+2, Type=244, Subtype=73, Switchtype=7, Image=8, DeviceID=uuid+DEV_VOLUME, Description=googleDevice.model_name, Used=0).Create() 491 | Domoticz.Device(Name=self.googleDevices[uuid].Name+" Playing", Unit=maxUnitNo+3, Type=244, Subtype=73, Switchtype=7, Image=12, DeviceID=uuid+DEV_PLAYING, Description=googleDevice.model_name, Used=0).Create() 492 | if (googleDevice.model_name.find("Chromecast") >= 0): 493 | Options = {"LevelActions": "", "LevelNames": "Off", "LevelOffHidden": "false", "SelectorStyle": "0"} 494 | Domoticz.Device(Name=self.googleDevices[uuid].Name+" Source", Unit=maxUnitNo+4, TypeName="Selector Switch", Switchtype=18, Image=12, DeviceID=uuid+DEV_SOURCE, Description=googleDevice.model_name, Used=0, Options=Options).Create() 495 | elif (googleDevice.model_name.find("Google Home") >= 0) or (googleDevice.model_name == "Google Cast Group"): 496 | pass 497 | else: 498 | Domoticz.Error("Unsupported device type: "+str(self.googleDevices[uuid])) 499 | 500 | except Exception as err: 501 | Domoticz.Error("discoveryCallback: "+str(err)) 502 | exc_type, exc_obj, exc_tb = sys.exc_info() 503 | fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] 504 | Domoticz.Error(str(exc_type) + ": " + fname + " at " + str(exc_tb.tb_lineno)) 505 | 506 | def onStart(self): 507 | if Parameters["Mode6"] != "0": 508 | Domoticz.Debugging(int(Parameters["Mode6"])) 509 | DumpConfigToLog() 510 | 511 | Parameters["Mode2"] = json.loads(Parameters["Mode2"].replace('|','"')) 512 | 513 | if Parameters['Key']+'Chromecast' not in Images: Domoticz.Image('ChromecastUltra.zip').Create() 514 | if Parameters['Key']+'HomeMini' not in Images: Domoticz.Image('GoogleHomeMini.zip').Create() 515 | 516 | # Mark devices as timed out 517 | if Parameters["Mode4"] != "False": 518 | for Device in Devices: 519 | UpdateDevice(Device, Devices[Device].nValue, Devices[Device].sValue, 1) 520 | 521 | if Parameters["Mode1"] != "": 522 | Domoticz.Notifier("Google_Devices") 523 | 524 | # Non-blocking asynchronous discovery, Nice ! 525 | self.stopDiscovery = pychromecast.get_chromecasts(callback=self.discoveryCallback, blocking=False) 526 | 527 | if (voiceEnabled): 528 | self.messageThread.start() 529 | else: 530 | Domoticz.Error("'gtts' module import error: "+voiceError+": Voice notifications will not be enabled") 531 | 532 | def onMessage(self, Connection, Data): 533 | 534 | try: 535 | if (Connection.Parent == self.messageServer): 536 | connectionOkay = True 537 | except AttributeError: 538 | Domoticz.Error("Please upgrade to the latest beta!!") 539 | connectionOkay = True 540 | 541 | # Callback connection for audible notifications 542 | if (connectionOkay): 543 | messageFile = None 544 | try: 545 | headerCode = "200 OK" 546 | if (not 'Verb' in Data): 547 | Domoticz.Error("Invalid web request received, no Verb present") 548 | headerCode = "400 Bad Request" 549 | elif (Data['Verb'] != 'GET'): 550 | Domoticz.Error("Invalid web request received, only GET requests allowed ("+Data['Verb']+")") 551 | headerCode = "405 Method Not Allowed" 552 | elif (not 'URL' in Data): 553 | Domoticz.Error("Invalid web request received, no URL present") 554 | headerCode = "400 Bad Request" 555 | elif (not 'Headers' in Data): 556 | Domoticz.Error("Invalid web request received, no Headers present") 557 | headerCode = "400 Bad Request" 558 | elif (not 'Range' in Data['Headers']): 559 | Domoticz.Error("Invalid web request received, no Range header present") 560 | headerCode = "400 Bad Request" 561 | elif (not os.path.exists(Parameters['HomeFolder']+'Messages'+Data['URL'])): 562 | Domoticz.Error("Invalid web request received, file '"+Parameters['HomeFolder']+'Messages'+Data['URL']+"' does not exist") 563 | headerCode = "404 File Not Found" 564 | 565 | if (headerCode != "200 OK"): 566 | DumpHTTPResponseToLog(Data) 567 | Connection.Send({"Status": headerCode}) 568 | else: 569 | # 'Range':'bytes=0-' 570 | range = Data['Headers']['Range'] 571 | fileStartPosition = int(range[range.find('=')+1:range.find('-')]) 572 | messageFileName = Parameters['HomeFolder']+'Messages'+Data['URL'] 573 | messageFileSize = os.path.getsize(messageFileName) 574 | messageFile = open(messageFileName, mode='rb') 575 | messageFile.seek(fileStartPosition) 576 | fileContent = messageFile.read(KB_TO_XMIT) 577 | Domoticz.Debug(Connection.Address+":"+Connection.Port+" Sent 'GET' request file '"+Data['URL']+"' from position "+str(fileStartPosition)+", "+str(len(fileContent))+" bytes will be returned") 578 | if (len(fileContent) == KB_TO_XMIT): 579 | headerCode = "206 Partial Content" 580 | Connection.Send({"Status":headerCode, "Headers": {"Content-Type": "audio/mp3", "Content-Range": "bytes "+str(fileStartPosition)+"-"+str(messageFile.tell())+"/"+str(messageFileSize)}, "Data":fileContent}) 581 | 582 | except Exception as inst: 583 | Domoticz.Error("Exception detail: '"+str(inst)+"'") 584 | DumpHTTPResponseToLog(Data) 585 | 586 | if (messageFile != None): 587 | messageFile.close() 588 | else: 589 | Domoticz.Error("Message from unknown connection: "+str(Connection)) 590 | 591 | def onCommand(self, Unit, Command, Level, Hue): 592 | global DEV_STATUS,DEV_VOLUME,DEV_PLAYING,DEV_SOURCE 593 | global APP_OTHER 594 | global Apps 595 | Domoticz.Log("onCommand called for Unit " + str(Unit) + ": Parameter '" + str(Command) + "', Level: " + str(Level)) 596 | 597 | Command = Command.strip() 598 | action, sep, params = Command.partition(' ') 599 | action = action.capitalize() 600 | 601 | # Map Unit number back to underlying Google device 602 | uuid = Devices[Unit].DeviceID[:-2] 603 | subUnit = Devices[Unit].DeviceID[-2:] 604 | # self.googleDevices[uuid] 605 | Domoticz.Debug("UUID: "+str(uuid)+", sub unit: "+subUnit+", Action: "+action+", params: "+params) 606 | 607 | if (action == 'On'): 608 | if (subUnit == DEV_VOLUME): 609 | self.googleDevices[uuid].GoogleDevice.set_volume_muted(False) 610 | elif (subUnit == DEV_PLAYING): 611 | self.googleDevices[uuid].GoogleDevice.media_controller.play() 612 | elif (action == 'Off'): 613 | if (subUnit == DEV_VOLUME): 614 | self.googleDevices[uuid].GoogleDevice.set_volume_muted(True) 615 | elif (subUnit == DEV_PLAYING): 616 | self.googleDevices[uuid].GoogleDevice.media_controller.pause() 617 | elif (subUnit == DEV_SOURCE): 618 | self.googleDevices[uuid].GoogleDevice.quit_app() 619 | elif (action == 'Set'): 620 | if (params.capitalize() == 'Level') or (Command.lower() == 'Volume'): 621 | if (subUnit == DEV_VOLUME): 622 | currentVolume = self.googleDevices[uuid].GoogleDevice.status.volume_level 623 | newVolume = Level / 100 624 | if (currentVolume > newVolume): 625 | self.googleDevices[uuid].GoogleDevice.volume_down(currentVolume-newVolume) 626 | else: 627 | self.googleDevices[uuid].GoogleDevice.volume_up(newVolume-currentVolume) 628 | elif (subUnit == DEV_PLAYING): 629 | if (self.googleDevices[uuid].GoogleDevice.media_controller.status.duration!=None): 630 | newPosition = self.googleDevices[uuid].GoogleDevice.media_controller.status.duration * (Level/100) 631 | self.googleDevices[uuid].GoogleDevice.media_controller.seek(newPosition) 632 | else: 633 | Domoticz.Log("["+self.googleDevices[uuid].Name+"] No duration found, seeking is not possible at this time.") 634 | elif (subUnit == DEV_SOURCE): 635 | seenApps = getConfigItem("Apps", Apps) 636 | for i, appName in enumerate(Devices[Unit].Options['LevelNames'].split("|")): 637 | if i*10 == Level: 638 | if (seenApps[appName]!=''): 639 | self.googleDevices[uuid].GoogleDevice.start_app(seenApps[appName]) 640 | break 641 | 642 | elif (action == 'Rewind'): 643 | self.googleDevices[uuid].GoogleDevice.media_controller.seek(0.0) 644 | elif (action == 'Play') or (action == 'Playing'): 645 | self.googleDevices[uuid].GoogleDevice.media_controller.play() 646 | elif (action == 'Pause') or (action == 'Paused'): 647 | self.googleDevices[uuid].GoogleDevice.media_controller.pause() 648 | elif (action == 'Trigger'): 649 | #mc.play_media('http://'+str(self.ip)+':'+str(self.Port)+'/message.mp3', 'music/mp3') 650 | x = 1 651 | elif (action == 'Video'): # Blockly command 652 | if (self.googleDevices[uuid].GoogleDevice.app_display_name != '') and (self.googleDevices[uuid].GoogleDevice.app_display_name != Parameters["Mode2"]["Video"]): 653 | self.googleDevices[uuid].GoogleDevice.quit_app() 654 | seenApps = getConfigItem("Apps", Apps) 655 | if (Parameters["Mode2"]["Video"] in seenApps): 656 | self.googleDevices[uuid].GoogleDevice.start_app(seenApps[Parameters["Mode2"]["Video"]]) 657 | elif (action == 'Audio'): # Blockly command 658 | if (self.googleDevices[uuid].GoogleDevice.app_display_name != '') and (self.googleDevices[uuid].GoogleDevice.app_display_name != Parameters["Mode2"]["Audio"]): 659 | self.googleDevices[uuid].GoogleDevice.quit_app() 660 | seenApps = getConfigItem("Apps", Apps) 661 | if (Parameters["Mode2"]["Audio"] in seenApps): 662 | self.googleDevices[uuid].GoogleDevice.start_app(seenApps[Parameters["Mode2"]["Audio"]]) 663 | elif (action == 'Sendnotification'): 664 | if (self.messageQueue != None): 665 | self.messageQueue.put({"Target":self.googleDevices[uuid].GoogleDevice.device.friendly_name, "Text":params}) 666 | else: 667 | Domoticz.Error("Message queue not initialized, notification ignored.") 668 | elif (action == 'Quit'): 669 | self.googleDevices[uuid].GoogleDevice.quit_app() 670 | 671 | def onHeartbeat(self): 672 | for uuid in self.googleDevices: 673 | self.googleDevices[uuid].UpdatePlaying() 674 | 675 | def onNotification(self, Name, Subject, Text, Status, Priority, Sound, ImageFile): 676 | Domoticz.Debug("onNotification: " + Name + "," + Subject + "," + Text + "," + Status + "," + str(Priority) + "," + Sound + "," + ImageFile) 677 | if (self.messageQueue != None): 678 | self.messageQueue.put({"Target":Parameters['Mode1'], "Text":Text}) 679 | else: 680 | Domoticz.Error("Message queue not initialized, notification ignored.") 681 | 682 | def onConnect(self, Connection, Status, Description): 683 | Domoticz.Debug(Connection.Address+":"+Connection.Port+" Connection established") 684 | 685 | def onDisconnect(self, Connection): 686 | Domoticz.Debug(Connection.Address+":"+Connection.Port+" Connection disconnected") 687 | 688 | def onStop(self): 689 | if (self.messageQueue != None): 690 | Domoticz.Log("Clearing notification queue (approximate size "+str(self.messageQueue.qsize())+" entries)...") 691 | self.messageQueue.put(None) 692 | 693 | for uuid in self.googleDevices: 694 | try: 695 | Domoticz.Log(self.googleDevices[uuid].Name+" Disconnecting...") 696 | self.googleDevices[uuid].GoogleDevice.disconnect(blocking=False) 697 | except Exception as err: 698 | Domoticz.Error("onStop: "+str(err)) 699 | 700 | if (self.stopDiscovery != None): 701 | Domoticz.Log("Zeroconf Discovery Stopping...") 702 | self.stopDiscovery() 703 | 704 | Domoticz.Log("Threads still active: "+str(threading.active_count())+", should be 1.") 705 | endTime = time.time() + 70 706 | while (threading.active_count() > 1) and (time.time() < endTime): 707 | for thread in threading.enumerate(): 708 | if (thread.name != threading.current_thread().name): 709 | Domoticz.Log("'"+thread.name+"' is still running (timeout in "+str(int(endTime - time.time()))[:4]+" seconds)") 710 | time.sleep(1.0) 711 | 712 | global _plugin 713 | _plugin = BasePlugin() 714 | 715 | def onStart(): 716 | global _plugin 717 | _plugin.onStart() 718 | 719 | def onStop(): 720 | global _plugin 721 | _plugin.onStop() 722 | 723 | def onMessage(Connection, Data): 724 | global _plugin 725 | _plugin.onMessage(Connection, Data) 726 | 727 | def onCommand(Unit, Command, Level, Hue): 728 | global _plugin 729 | _plugin.onCommand(Unit, Command, Level, Hue) 730 | 731 | def onConnect(Connection, Status, Description): 732 | global _plugin 733 | _plugin.onConnect(Connection, Status, Description) 734 | 735 | def onDisconnect(Connection): 736 | global _plugin 737 | _plugin.onDisconnect(Connection) 738 | 739 | def onHeartbeat(): 740 | global _plugin 741 | _plugin.onHeartbeat() 742 | 743 | def onNotification(Name, Subject, Text, Status, Priority, Sound, ImageFile): 744 | global _plugin 745 | _plugin.onNotification(Name, Subject, Text, Status, Priority, Sound, ImageFile) 746 | 747 | # Network helper functions 748 | def GetIP(): 749 | import socket 750 | IP = '' 751 | s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 752 | try: 753 | s.connect(('8.8.8.8', 1)) 754 | IP = s.getsockname()[0] 755 | Domoticz.Debug("IP Address is: "+str(IP)) 756 | except Exception as err: 757 | Domoticz.Debug("GetIP: "+str(err)) 758 | finally: 759 | s.close() 760 | return str(IP) 761 | 762 | # Configuration Helpers 763 | def getConfigItem(Key=None, Default={}): 764 | Value = Default 765 | try: 766 | Config = Domoticz.Configuration() 767 | if (Key != None): 768 | Value = Config[Key] # only return requested key if there was one 769 | else: 770 | Value = Config # return the whole configuration if no key 771 | except KeyError: 772 | Value = Default 773 | except Exception as inst: 774 | Domoticz.Error("Domoticz.Configuration read failed: '"+str(inst)+"'") 775 | return Value 776 | 777 | def setConfigItem(Key=None, Value=None): 778 | Config = {} 779 | try: 780 | Config = Domoticz.Configuration() 781 | if (Key != None): 782 | Config[Key] = Value 783 | else: 784 | Config = Value # set whole configuration if no key specified 785 | Domoticz.Configuration(Config) 786 | except Exception as inst: 787 | Domoticz.Error("Domoticz.Configuration operation failed: '"+str(inst)+"'") 788 | return Config 789 | 790 | # Generic helper functions 791 | def stringOrBlank(input): 792 | if (input == None): return "" 793 | else: return str(input) 794 | 795 | def DumpConfigToLog(): 796 | for x in Parameters: 797 | if Parameters[x] != "": 798 | Domoticz.Debug( "'" + x + "':'" + str(Parameters[x]) + "'") 799 | Domoticz.Debug("Device count: " + str(len(Devices))) 800 | for x in Devices: 801 | Domoticz.Debug("Device: " + str(x) + " - " + str(Devices[x])) 802 | Domoticz.Debug("Device ID: '" + str(Devices[x].ID) + "'") 803 | Domoticz.Debug("Device Name: '" + Devices[x].Name + "'") 804 | Domoticz.Debug("Device nValue: " + str(Devices[x].nValue)) 805 | Domoticz.Debug("Device sValue: '" + Devices[x].sValue + "'") 806 | Domoticz.Debug("Device LastLevel: " + str(Devices[x].LastLevel)) 807 | return 808 | 809 | def DumpHTTPResponseToLog(httpDict): 810 | if isinstance(httpDict, dict): 811 | Domoticz.Log("HTTP Details ("+str(len(httpDict))+"):") 812 | for x in httpDict: 813 | if isinstance(httpDict[x], dict): 814 | Domoticz.Log("--->'"+x+" ("+str(len(httpDict[x]))+"):") 815 | for y in httpDict[x]: 816 | Domoticz.Log("------->'" + y + "':'" + str(httpDict[x][y]) + "'") 817 | else: 818 | Domoticz.Log("--->'" + x + "':'" + str(httpDict[x]) + "'") 819 | 820 | def UpdateDevice(Unit, nValue, sValue, TimedOut): 821 | # Make sure that the Domoticz device still exists (they can be deleted) before updating it 822 | if (Unit in Devices): 823 | if (str(Devices[Unit].nValue) != str(nValue)) or (str(Devices[Unit].sValue) != str(sValue)) or (str(Devices[Unit].TimedOut) != str(TimedOut)): 824 | Domoticz.Log("["+Devices[Unit].Name+"] Update "+str(nValue)+"("+str(Devices[Unit].nValue)+"):'"+sValue+"'("+Devices[Unit].sValue+"): "+str(TimedOut)+"("+str(Devices[Unit].TimedOut)+")") 825 | Devices[Unit].Update(nValue=nValue, sValue=str(sValue), TimedOut=TimedOut) 826 | return 827 | 828 | def UpdateImage(Unit, Logo): 829 | if Unit in Devices and Logo in Images: 830 | if Devices[Unit].Image != Images[Logo].ID: 831 | Domoticz.Log("Device Image update: 'Chromecast', Currently " + str(Devices[Unit].Image) + ", should be " + str(Images[Logo].ID)) 832 | Devices[Unit].Update(nValue=Devices[Unit].nValue, sValue=str(Devices[Unit].sValue), Image=Images[Logo].ID) 833 | return 834 | --------------------------------------------------------------------------------