├── 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 |
18 |
Devices are discovered automatically and created in the Devices tab
19 |
When network connectivity is lost the Domoticz UI will optionally show the device(s) with Red banner
20 |
Device icons are created in Domoticz
21 |
Domoticz can control the Application selected
22 |
Domoticz can control the Volume including Mute/Unmute
23 |
Domoticz can control the playing media. Play/Pause and skip forward and backwards
24 |
Google devices can be the targets of native Domoticz notifications. These are spoken through a specified device (or audio group) in the language specified in Domoticz
25 |
Voice notifications can be sent to selected Google devices from event scripts (Lua or Python)
Playing - Icon Pauses/Resumes, slider shows/sets percentage through media
33 |
34 |
Configuration
35 |
36 |
Preferred Video/Audio Apps - Application to select when scripts request 'Video' or 'Audio' mode from a script
37 |
Voice message volume - Volume to play messages (previous level will be restored afterwards)
38 |
Voice Device/Group - If specified device (or Audio Group) will receive audible notifications. 'Google_Devices' will appear as a notification target when editing any Domoticz device that supports Notifications
39 |
Time Out Lost Devices - When true, the devices in Domoticz will have a red banner when network connectivity is lost
40 |
Log to file - When true, messages from Google devices are written to Messages.log in the Plugin's directory
41 |
Debug - When true the logging level will be much higher to aid with troubleshooting
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
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 |
--------------------------------------------------------------------------------