-
Notifications
You must be signed in to change notification settings - Fork 36
/
Copy pathharmonize.py
executable file
·486 lines (413 loc) · 21.6 KB
/
harmonize.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
#!/usr/bin/env python3
########################################
########## Harmonize Project ###########
########## by ###########
########## MCP Capital, LLC ###########
########################################
# Github.com/MCPCapital/harmonizeproject
# Script Last Updated - Release 2.4.2
########################################
# -v to enable verbose messages #
# -g # to pre-select a group number #
# -b # to pre-select a bridge by id #
# -i # to pre-select bridge IP #
# -w # to pre-select video wait time #
# -f # to pre-select stream filename #
# -s # single light source optimized #
# -l # to adjust maximum brightness #
# -a # restart stream after x seconds #
########################################
import sys
import argparse
import requests
import time
import json
import socket
import subprocess
import threading
import fileinput
import numpy as np
import cv2
import re
from pathlib import Path
from zeroconf import ServiceBrowser, ServiceListener, Zeroconf
from termcolor import colored
# suppress SSL certificate verification warning
from requests.packages.urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
class MyListener(ServiceListener):
bridgelist = []
def update_service(self, zeroconf, type, name):
print(f"INFO: Received Bridge mDNS update.")
def remove_service(self, zeroconf, type, name):
print(f"INFO: Bridge removed from mDNS network.")
def add_service(self, zeroconf, type, name):
info = zeroconf.get_service_info(type, name)
self.bridgelist.append(info.parsed_addresses()[0])
verbose("INFO: Detected %s via mDNS at IP address: %s" % (name, info.parsed_addresses()[0]))
zeroconf = Zeroconf()
listener = MyListener()
parser = argparse.ArgumentParser()
parser.add_argument("-v","--verbose", dest="verbose", action="store_true")
parser.add_argument("-g","--groupid", dest="groupid")
parser.add_argument("-b","--bridgeid", dest="bridgeid")
parser.add_argument("-i","--bridgeip", dest="bridgeip")
parser.add_argument("-s","--single_light", dest="single_light", action="store_true")
parser.add_argument("-w","--video_wait_time", dest="video_wait_time", type=float, default=5.0)
parser.add_argument("-f","--stream_filename", dest="stream_filename")
parser.add_argument("-l","--light_brightness", dest="light_brightness", type=int, default=30)
parser.add_argument("-a","--auto_restart", dest="auto_restart", type=int, default=0)
commandlineargs = parser.parse_args()
is_single_light = False
stopped = False
def eprint(*args, **kwargs):
print(*args, file=sys.stderr, **kwargs)
def verbose(*args, **kwargs):
if commandlineargs.verbose==True:
print(*args, **kwargs)
######### Initialization Complete - Now lets try and connect to the bridge ##########
def findhue(): #Auto-find bridges on mDNS network
try:
browser = ServiceBrowser(zeroconf, "_hue._tcp.local.", listener)
except (
zeroconf.BadTypeInNameException,
NotImplementedError,
OSError,
socket.error,
zeroconf.NonUniqueNameException,
) as exc:
print("ERROR: Cannot create mDNS service discovery browser: {}".format(exc))
sys.exit(1)
# wait half a second for mDNS discovery lookup
time.sleep(0.5)
if len(listener.bridgelist) == 1:
print("INFO: Single Hue bridge detected on network via mDNS.")
return listener.bridgelist[0]
bridgelist = []
if len(listener.bridgelist) == 0:
print("INFO: Bridge not detected via mDNS lookup, will try via discovery method.")
try:
r = requests.get("https://discovery.meethue.com/")
except:
sys.exit("ERROR: Discovery method did not execute properly. Please try again later. Exiting application.")
bridgelist = json.loads(r.text)
if len(bridgelist) == 1:
print("INFO: Single Hue bridge detected via network discovery.")
return bridgelist[0]['internalipaddress']
if commandlineargs.bridgeid is not None:
for idx, b in enumerate(bridgelist):
if b["id"] == commandlineargs.bridgeid:
return bridgelist[idx]['internalipaddress']
sys.exit("ERROR: Bridge {} was not found".format(commandlineargs.bridgeid))
elif commandlineargs.bridgeip is not None:
for idx, b in enumerate(bridgelist):
if b["internalipaddress"] == commandlineargs.bridgeip:
return commandlineargs.bridgeip
sys.exit("ERROR: Bridge {} was not found".format(commandlineargs.bridgeip))
# if multiple bridges detected via mDNS
if len(listener.bridgelist)>1:
print("Multiple bridges found via mDNS lookup. Key a number corresponding to the list of bridges below:")
for index, value in enumerate(listener.bridgelist):
print("[" + str(index+1) + "]:", value)
if commandlineargs.bridgeip is not None:
for idx, b in enumerate(listener.bridgelist):
if b == commandlineargs.bridgeip:
return commandlineargs.bridgeip
sys.exit("ERROR: Bridge {} was not found".format(commandlineargs.bridgeip))
else:
bridge = int(input())
return listener.bridgelist[bridge-1]
# if multiple bridges detected via network discovery
if len(bridgelist)>1:
print("Multiple bridges found via network discovery. Key a number corresponding to the list of bridges below:")
for index, value in enumerate(bridgelist):
print("[" + str(index+1) + "]:", value)
bridge = int(input())
return bridgelist[bridge-1]['internalipaddress']
return None
def register():
global clientdata
print("INFO: Device not registered on bridge")
payload = {"devicetype":"harmonizehue","generateclientkey":True}
print("INFO: You have 60 seconds to press the large button on the bridge! Checking for button press every 5 seconds.")
attempts = 1
while attempts < 12:
r = requests.post(("http://%s/api" % (hueip)), json.dumps(payload))
bridgeresponse = json.loads(r.text)
if 'error' in bridgeresponse[0]:
print("WARNING {}: {}".format(attempts, bridgeresponse[0]['error']['description']))
elif('success') in bridgeresponse[0]:
# generate client.json file
clientdata = bridgeresponse[0]["success"]
f = open("client.json", "w")
f.write(json.dumps(clientdata))
f.close()
print("INFO: Username and client key generated to access the bridge Entertainment API functionality.")
break
else:
print("INFO: No response detected.")
attempts += 1
time.sleep(5)
else:
print("ERROR: Button press not detected, exiting application.")
exit()
### Start of main program ###
print(colored('--- Starting Harmonize Project ---','green'))
hueip = findhue() or None
if hueip is None:
sys.exit("ERROR: Hue bridge not found. Please ensure proper network connectivity and power connection to the hue bridge.")
verbose("INFO: Hue bridge located at:", hueip)
baseurl = "http://{}/api".format(hueip)
clientdata = []
verbose("Checking whether Harmonizer application is already registered (Looking for client.json file).")
if Path("./client.json").is_file():
f = open("client.json", "r")
jsonstr = f.read()
clientdata = json.loads(jsonstr)
f.close()
verbose("INFO: Client data found from client.json file.")
setupurl = baseurl + "/" + clientdata['username']
r = requests.get(url = setupurl)
setupresponse = dict()
setupresponse = json.loads(r.text)
if setupresponse.get('error'):
verbose("INFO: Client data no longer valid.")
register()
else:
verbose("INFO: Client data valid:", clientdata)
else:
register()
verbose("Requesting bridge information...")
r = requests.get(url = baseurl+"/config")
jsondata = r.json()
if jsondata["swversion"]<"1948086000": #Check if the bridge supports streaming via API v1/v2
sys.exit("ERROR: Firmware software version on the bridge is outdated and does not support streaming on APIv1/v2. Upgrade it using Hue app. Software version must be 1.XX.194808600 or greater.")
else:
verbose("INFO: The bridge is capable of streaming via APIv2. Firmware version {} detected...".format(jsondata["swversion"]))
######### We're connected! - Now lets find entertainment areas in the list of groups via API v2 ##########
print("Querying hue bridge for entertainment areas on local network.")
r_v2 = requests.get("https://{}/clip/v2/resource/entertainment_configuration".format(hueip), verify=False, headers={"hue-application-key":clientdata['username']})
json_data_v2 = json.loads(r_v2.text)
#print(json_data_v2)
groupid = commandlineargs.groupid
if len(json_data_v2['data'])==0: #No entertainment areas or null = exit
if groupid is not None:
sys.exit("Entertainment area specified in command line argument groupid not found.")
else:
sys.exit("No Entertainment areas found. Please ensure at least one has been created in the Hue App.")
if len(json_data_v2['data'])==1:
groupid = re.findall(r'\d+', json_data_v2['data'][0]['id_v1'])[0]
entertainment_id = json_data_v2['data'][0]['id']
print("groupid = ",groupid)
if len(json_data_v2['data'])>1:
print("Multiple Entertainment areas found. Type in the number corresponding to the area you wish to use and hit Enter. (You can specify which with the optional argument --groupid ).")
for value in json_data_v2['data']:
print("[ " + str(re.findall(r'\d+',value['id_v1'])[0]) + " ]: " + str(value['name']))
if groupid is None:
groupid = input()
print("Using Entertainment area with group_id: {}".format(groupid))
# find entertainment_id from legacy group_id
for value in json_data_v2['data']:
if groupid == re.findall(r'\d+',value['id_v1'])[0]:
entertainment_id = str(value['id'])
verbose("Selected Entertainment UDID:",entertainment_id)
#### Lets get the lights & their locations in our selected group and enable streaming ######
r_v2 = requests.get("https://{}/clip/v2/resource/entertainment_configuration/{}".format(hueip,entertainment_id), verify=False, headers={"hue-application-key":clientdata['username']}) # via APIv2
lights_data = json.loads(r_v2.text)
lights_dict = dict()
for index, value in enumerate(lights_data['data'][0]['channels']):
#print(str(index) + " and " + str(value['position']))
lights_dict.update({str(index): [value['position']['x'],value['position']['y'], value['position']['z']]})
verbose("INFO: {} light(s) found in selected Entertainment area. Locations [x,y,z] are as follows: \n".format(len(lights_dict)), lights_dict)
# Exit streaming application if number of lights is greater than 20
if len(lights_dict) > 20:
sys.exit("ERROR: {} light(s) found. The maximum allowable is up to 20 per Entertainment area. Exiting application.".format(len(lights_dict)))
# Retrieve PSK identify for APIv2
r_v2 = requests.get("https://{}/auth/v1".format(hueip), verify=False, headers={"hue-application-key":clientdata['username']}) # via APIv2
hue_app_id = r_v2.headers['hue-application-id']
verbose("Hue application id:",hue_app_id)
##### Setting up streaming service and calling the DTLS handshake command ######
verbose("Enabling streaming to your Entertainment area") #Allows us to send UPD packets to port 2100
r_v2 = requests.put("https://{}/clip/v2/resource/entertainment_configuration/{}".format(hueip,entertainment_id), json={"action":"start"}, verify=False, headers={"hue-application-key":clientdata['username']}) # via APIv2
jsondata = r_v2.json()
verbose(jsondata)
######### Prepare the messages' vessel for the RGB values we will insert
bufferlock = threading.Lock()
######################################################
################# Setup Complete #####################
######################################################
######################################################
### Scaling light locations and averaging colors #####
######################################################
########## Scales up locations to identify the nearest pixel based on lights' locations #######
def averageimage():
for x, coords in lights_dict.items():
coords[0] = ((coords[0])+1) * w//2 #Translates x value and resizes to video aspect ratio
coords[2] = (-1*(coords[2])+1) * h//2 #Flips y, translates, and resize to vid aspect ratio
scaled_locations = list(lights_dict.items()) #Makes it a list of locations by light
verbose("INFO: Lights and locations (in order) scaled using video resolution input are as follows: ", scaled_locations)
#### This section assigns light locations to variable light1,2,3...etc. in JSON order
avgsize = w/2 + h/2
verbose('INFO: Average number of total pixels of video area to be analyzed is:', avgsize)
breadth = .15 #approx percent of the screen outside the location to capture
dist = int(breadth*avgsize) #Proportion of the pixels we want to average around in relation to the video size
verbose('INFO: Distance in pixels from relative location is:', dist)
global cords #array of coordinates
global bounds #array of bounds for each coord, each item is formatted as [top, bottom, left, right]
#initialize the arrays
cords = {}
bounds = {}
for num, cds in scaled_locations:
cords[num] = cds
bds = [cds[2] - dist, cds[2] + dist, cds[0] - dist, cds[0] + dist]
bds = list(map(int, bds))
bds = list(map(lambda x: 0 if x < 0 else x, bds))
bounds[num] = bds
global rgb,rgb_bytes #array of rgb values, one for each light
rgb = {}
rgb_bytes = {}
area = {}
# Constantly sets RGB values by location via taking average of nearby pixels
while not stopped:
for x, bds in bounds.items():
area[x] = rgbframe[bds[0]:bds[1], bds[2]:bds[3], :]
rgb[x] = cv2.mean(area[x])
for x, c in rgb.items():
rgb_bytes[x] = bytearray([int(c[0]/2), int(c[0]/2), int(c[1]/2), int(c[1]/2), int(c[2]/2), int(c[2]/2),] )
######################################################
############ Video Capture Setup #####################
######################################################
######### Initialize the video device for capture using OpenCV
def init_video_capture():
try:
if commandlineargs.stream_filename is None:
#cap = cv2.VideoCapture(0,cv2.CAP_FFMPEG) #variable cap is our raw video input
cap = cv2.VideoCapture(0,cv2.CAP_GSTREAMER) #variable cap is our raw video input
else:
cap = cv2.VideoCapture(commandlineargs.stream_filename) #capture from given file/url
except:
sys.exit("ERROR: Issue enabling video capture")
if cap.isOpened(): # Try to get the first frame
verbose('INFO: Capture device opened using OpenCV.')
else: #Makes sure we can access the device
sys.exit('ERROR: Unable to open capture device.') #quit
return cap
######################################################
############ Frame Grabber ###########################
######################################################
######### Now that weve defined our RGB values as bytes, we define how we pull values from the video analyzer output
def cv2input_to_buffer(): ######### Section opens the device, sets buffer, pulls W/H
global w,h,rgbframe, channels, cap
cap = init_video_capture()
w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) # gets video width
h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) # gets video height
verbose("INFO: Video frame size (W by H): {} by {}".format(w, h)) #prints video frame size
########## This section loops & pulls re-colored frames and alwyas get the newest frame
cap.set(cv2.CAP_PROP_BUFFERSIZE,0) # No frame buffer to avoid lagging, always grab newest frame
missed_frame_count = 0
while not stopped:
ret, bgrframe = cap.read() # processes most recent frame
if ret: # if frame is read properly
if is_single_light:
channels = cv2.mean(bgrframe)
else:
bgrframe = adjust_brightness(bgrframe,commandlineargs.light_brightness)
rgbframe = cv2.cvtColor(bgrframe, cv2.COLOR_BGR2RGB) #corrects BGR to RGB
else:
print("WARNING: Unable to read frame from video stream")
time.sleep(1)
missed_frame_count += 1
if commandlineargs.auto_restart > 0:
if missed_frame_count % commandlineargs.auto_restart == 0: # restart after certain amount of seconds have passed
cap.open(0)
def adjust_brightness(raw, value):
hsv = cv2.cvtColor(raw, cv2.COLOR_BGR2HSV)
h, s, v = cv2.split(hsv)
lim = 255 - value
v[v > lim] = 255
v[v <= lim] += value
final_hsv = cv2.merge((h, s, v))
raw = cv2.cvtColor(final_hsv, cv2.COLOR_HSV2BGR)
return raw
######################################################
############## Sending the messages ##################
######################################################
######### This is where we define our message format and insert our light#s, RGB values, and X,Y,Brightness ##########
def buffer_to_light(proc): #Potentially thread this into 2 processes?
time.sleep(1.5) #Hold on so DTLS connection can be made & message format can get defined
while not stopped:
bufferlock.acquire()
message = bytes('HueStream','utf-8') + b'\2\0\0\0\0\0\0' + bytes(entertainment_id,'utf-8')
if is_single_light:
single_light_bytes = bytearray([int(channels[2]/2), int(channels[2]/2), int(channels[1]/2), int(channels[1]/2), int(channels[0]/2), int(channels[0]/2),] ) # channels corrected here from BGR to RGB
message += bytes(chr(int(1)), 'utf-8') + single_light_bytes
else:
for i in rgb_bytes:
message += bytes(chr(int(i)), 'utf-8') + rgb_bytes[i]
bufferlock.release()
proc.stdin.write(message.decode('utf-8','ignore'))
time.sleep(.0167) #0.01 to 0.02 (slightly under 100 or 50 messages per sec // or (.0167 = ~60))
proc.stdin.flush()
#verbose('Wrote message and flushed. Briefly waiting') #This will verbose after every send, spamming the console.
######################################################
############### Initialization Area ##################
######################################################
######### Section executes video input and establishes the connection stream to bridge ##########
try:
try:
threads = list()
print(colored('Starting computer vision engine...','cyan'))
verbose("OpenCV version:",cv2.__version__)
try:
if commandlineargs.stream_filename is None:
subprocess.check_output("ls -ltrh /dev/video0",shell=True)
print("INFO: Detected video capture card on /dev/video0")
except subprocess.CalledProcessError:
print("ERROR: Video capture card not detected on /dev/video0")
else:
# Check to see if video stream is actually being captured properly before continuing.
cap_test = init_video_capture()
w = int(cap_test.get(cv2.CAP_PROP_FRAME_WIDTH)) # gets video width
try: w
except NameError: sys.exit("Error capturing stream. Exiting application.")
cap_test.release()
t = threading.Thread(target=cv2input_to_buffer)
t.start()
threads.append(t)
print("Initializing video frame grabber...")
time.sleep(commandlineargs.video_wait_time) # wait sufficiently until rgbframe is defined
if (commandlineargs.single_light is True) and (len(lights_dict)==1):
is_single_light = True
print("Enabled optimization for single light source") # averager thread is not utilized
else:
is_single_light = False
verbose("Starting image analyzer...")
t = threading.Thread(target=averageimage)
t.start()
threads.append(t)
time.sleep(0.50) # wait sufficiently until rgb_bytes is defined from above thread
verbose("Opening an SSL packet stream to lights on network...")
cmd = ["openssl","s_client","-dtls1_2","-cipher","PSK-AES128-GCM-SHA256","-psk_identity",hue_app_id,"-psk",clientdata['clientkey'], "-connect", hueip+":2100"]
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
t = threading.Thread(target=buffer_to_light, args=(proc,))
t.start()
threads.append(t)
while not stopped:
key_input = input("Please r to reset the video capture, q to stop streaming, followed by Enter: ")
print(key_input)
if key_input == 'r':
print("Resetting video capture...")
cap.open(0)
if key_input == 'q':
stopped = True
for t in threads:
t.join()
except Exception as e:
print(e)
stopped=True
finally: #Turn off streaming to allow normal function immedietly
zeroconf.close()
print("Disabling streaming on Entertainment area...")
r = requests.put("https://{}/clip/v2/resource/entertainment_configuration/{}".format(hueip,entertainment_id), json={"action":"stop"}, verify=False, headers={"hue-application-key":clientdata['username']})
jsondata = r.json()
verbose(jsondata)