├── .gitignore ├── LICENSE ├── README.md ├── convertor_lib.py ├── discovery_lib.py ├── example ├── dark_or_bright_background.jpg ├── hue_sync_app.jpg ├── image_after_kmeans.png ├── light_sync_app.jpg ├── mask.png ├── masked_image.jpg └── test_image.jpg ├── frame_color_lib.py ├── light_sync.py └── phue_lib.py /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .DS_Store -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 digital-concrete 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 | # light-sync: A Python script for Philips Hue 2 | Sync Philips Hue lights with computer screen in real time 3 | 4 | [![sample run](https://img.youtube.com/vi/GCckl4853TY/1.jpg)](https://www.youtube.com/watch?v=GCckl4853TY) 5 | 6 | 7 | ## Prerequisites: 8 | - Python environment 9 | - Pip: ```apt get install python-pip``` 10 | - OpenCV Python: ```pip install opencv-python``` 11 | - MSS: ```pip install mss``` 12 | - Requests: ```pip install requests``` 13 | 14 | ## Usage: 15 | 16 | Make sure you set the ```MY_LIGHT_NAMES```/```MY_LIGHT_IDS``` and ```BRIDGE_IP``` variables to match your Hue System. 17 | 18 | **Press the Hue Bridge button before running the script for the first time!** 19 | 20 | Run the script in the terminal: 21 | 22 | ``` 23 | python light_sync.py 24 | ``` 25 | 26 | For stereo mode start 2 instances of the script (make sure to divide by 2 the ```HUE_MAX_REQUESTS_PER_SECOND``` variable: 27 | 28 | ``` 29 | python light_sync.py --screenpart left --lights Light1 30 | python light_sync.py --screenpart right --lights Light2 31 | ``` 32 | 33 | Feel free to tweak setup variables in order to obtain preferred effect. 34 | 35 | Enjoy! 36 | 37 | ## Desktop app for Linux, Mac, Windows available: 38 | 39 | ![light_sync_app](example/light_sync_app.jpg) 40 | 41 | https://github.com/digital-concrete/light-sync-electron 42 | 43 | ## How it works: 44 | 45 | 1) Use **Python MSS** to grab a screen capture 46 | 47 | 2) Shrink the captured image in order to increase computation speed. Check out ```INPUT_IMAGE_REDUCED_SIZE``` variable. 48 | 49 | 3) Use ```cv2.matchTemplate``` to check the difference between current and previously used frame. If the difference is smaller than the ```FRAME_MATCH_SENSITIVITY``` variable then the frame is skipped in order to prevent redundant calculations and requests. 50 | Let's suppose we have following frame: 51 | ![example](example/test_image.jpg) 52 | 53 | 4) Make a grayscale copy of the image and apply OpenCV threshold function in order to calcuate mask: 54 | ![mask](example/mask.png) 55 | 56 | 5) Apply mask to image: 57 | ![masked_image](example/masked_image.jpg) 58 | 59 | 6) Compare the count of non zero value pixels in the image with ```MIN_NON_ZERO_COUNT```. 60 | If the count is too little, then turn off/dim the lights. 61 | If ```VARIABLE_BRIGHTNESS_MODE``` is set to true then brightness is calculated based on the non zero pixel count. 62 | If ```DIM_LIGHTS_INSTEAD_OF_TURN_OFF``` is set to true then, whenever the count is below the lower threshold ```MIN_NON_ZERO_COUNT```, the lights are being dimmed instead of turned off. 63 | 64 | 3) Apply **OpenCV K Means Clustering** in order to find main image colors. 65 | Result will look like: 66 | ![result](example/image_after_kmeans.png) 67 | 68 | 4) Calculate which of the colors calculated in step 3 should be sent to Philips Hue lights. 69 | If the most prevalent color is either too dark or too bright it means that we have an image with bright or dark background. In this case we look for the next color until we find a color that suits the conditions. 70 | ![dark_bright_background](example/dark_or_bright_background.jpg) 71 | 72 | 5) Check whether the calculated color is different than the one already in use. Tweak ```COLOR_SKIP_SENSITIVITY``` variable to change the sensitivity. 73 | 74 | 6) Finally, we have the ```CAN_UPDATE_HUE``` variable that allows us to update the lights. This variable is used to prevent bridge request bottlenecks and is cleared by a timeout. Timeout duration can be adjusted by changing ```HUE_MAX_REQUESTS_PER_SECOND``` variable. 75 | 76 | 7) If the above flag is clear we can finally update the lights: change color, brightness, dim or switch them on/off :) 77 | 78 | 79 | ## Based on: 80 | 81 | http://python-mss.readthedocs.io/examples.html 82 | 83 | https://docs.opencv.org/3.0-beta/doc/py_tutorials/py_ml/py_kmeans/py_kmeans_opencv/py_kmeans_opencv.html 84 | 85 | https://www.developers.meethue.com/ 86 | 87 | https://github.com/studioimaginaire/phue 88 | 89 | https://github.com/benknight/hue-python-rgb-converter -------------------------------------------------------------------------------- /convertor_lib.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Library for RGB / CIE1931 "x, y" coversion. 4 | Based on Philips implementation guidance: 5 | http://www.developers.meethue.com/documentation/color-conversions-rgb-xy 6 | Copyright (c) 2016 Benjamin Knight / MIT License. 7 | """ 8 | import math 9 | import random 10 | from collections import namedtuple 11 | 12 | __version__ = '0.5' 13 | 14 | # Represents a CIE 1931 XY coordinate pair. 15 | XYPoint = namedtuple('XYPoint', ['x', 'y']) 16 | 17 | # LivingColors Iris, Bloom, Aura, LightStrips 18 | GamutA = ( 19 | XYPoint(0.704, 0.296), 20 | XYPoint(0.2151, 0.7106), 21 | XYPoint(0.138, 0.08), 22 | ) 23 | 24 | # Hue A19 bulbs 25 | GamutB = ( 26 | XYPoint(0.675, 0.322), 27 | XYPoint(0.4091, 0.518), 28 | XYPoint(0.167, 0.04), 29 | ) 30 | 31 | # Hue BR30, A19 (Gen 3), Hue Go, LightStrips plus 32 | GamutC = ( 33 | XYPoint(0.692, 0.308), 34 | XYPoint(0.17, 0.7), 35 | XYPoint(0.153, 0.048), 36 | ) 37 | 38 | 39 | def get_light_gamut(modelId): 40 | """Gets the correct color gamut for the provided model id. 41 | Docs: http://www.developers.meethue.com/documentation/supported-lights 42 | """ 43 | if modelId in ('LST001', 'LLC010', 'LLC011', 'LLC012', 'LLC006', 'LLC007', 'LLC013'): 44 | return GamutA 45 | elif modelId in ('LCT001', 'LCT007', 'LCT002', 'LCT003', 'LLM001'): 46 | return GamutB 47 | elif modelId in ('LCT010', 'LCT014', 'LCT011', 'LLC020', 'LST002'): 48 | return GamutC 49 | else: 50 | raise ValueError 51 | return None 52 | 53 | 54 | class ColorHelper: 55 | 56 | def __init__(self, gamut=GamutB): 57 | self.Red = gamut[0] 58 | self.Lime = gamut[1] 59 | self.Blue = gamut[2] 60 | 61 | def hex_to_red(self, hex): 62 | """Parses a valid hex color string and returns the Red RGB integer value.""" 63 | return int(hex[0:2], 16) 64 | 65 | def hex_to_green(self, hex): 66 | """Parses a valid hex color string and returns the Green RGB integer value.""" 67 | return int(hex[2:4], 16) 68 | 69 | def hex_to_blue(self, hex): 70 | """Parses a valid hex color string and returns the Blue RGB integer value.""" 71 | return int(hex[4:6], 16) 72 | 73 | def hex_to_rgb(self, h): 74 | """Converts a valid hex color string to an RGB array.""" 75 | rgb = (self.hex_to_red(h), self.hex_to_green(h), self.hex_to_blue(h)) 76 | return rgb 77 | 78 | def rgb_to_hex(self, r, g, b): 79 | """Converts RGB to hex.""" 80 | return '%02x%02x%02x' % (r, g, b) 81 | 82 | def random_rgb_value(self): 83 | """Return a random Integer in the range of 0 to 255, representing an RGB color value.""" 84 | return random.randrange(0, 256) 85 | 86 | def cross_product(self, p1, p2): 87 | """Returns the cross product of two XYPoints.""" 88 | return (p1.x * p2.y - p1.y * p2.x) 89 | 90 | def check_point_in_lamps_reach(self, p): 91 | """Check if the provided XYPoint can be recreated by a Hue lamp.""" 92 | v1 = XYPoint(self.Lime.x - self.Red.x, self.Lime.y - self.Red.y) 93 | v2 = XYPoint(self.Blue.x - self.Red.x, self.Blue.y - self.Red.y) 94 | 95 | q = XYPoint(p.x - self.Red.x, p.y - self.Red.y) 96 | s = self.cross_product(q, v2) / self.cross_product(v1, v2) 97 | t = self.cross_product(v1, q) / self.cross_product(v1, v2) 98 | 99 | return (s >= 0.0) and (t >= 0.0) and (s + t <= 1.0) 100 | 101 | def get_closest_point_to_line(self, A, B, P): 102 | """Find the closest point on a line. This point will be reproducible by a Hue lamp.""" 103 | AP = XYPoint(P.x - A.x, P.y - A.y) 104 | AB = XYPoint(B.x - A.x, B.y - A.y) 105 | ab2 = AB.x * AB.x + AB.y * AB.y 106 | ap_ab = AP.x * AB.x + AP.y * AB.y 107 | t = ap_ab / ab2 108 | 109 | if t < 0.0: 110 | t = 0.0 111 | elif t > 1.0: 112 | t = 1.0 113 | 114 | return XYPoint(A.x + AB.x * t, A.y + AB.y * t) 115 | 116 | def get_closest_point_to_point(self, xy_point): 117 | # Color is unreproducible, find the closest point on each line in the CIE 1931 'triangle'. 118 | pAB = self.get_closest_point_to_line(self.Red, self.Lime, xy_point) 119 | pAC = self.get_closest_point_to_line(self.Blue, self.Red, xy_point) 120 | pBC = self.get_closest_point_to_line(self.Lime, self.Blue, xy_point) 121 | 122 | # Get the distances per point and see which point is closer to our Point. 123 | dAB = self.get_distance_between_two_points(xy_point, pAB) 124 | dAC = self.get_distance_between_two_points(xy_point, pAC) 125 | dBC = self.get_distance_between_two_points(xy_point, pBC) 126 | 127 | lowest = dAB 128 | closest_point = pAB 129 | 130 | if (dAC < lowest): 131 | lowest = dAC 132 | closest_point = pAC 133 | 134 | if (dBC < lowest): 135 | lowest = dBC 136 | closest_point = pBC 137 | 138 | # Change the xy value to a value which is within the reach of the lamp. 139 | cx = closest_point.x 140 | cy = closest_point.y 141 | 142 | return XYPoint(cx, cy) 143 | 144 | def get_distance_between_two_points(self, one, two): 145 | """Returns the distance between two XYPoints.""" 146 | dx = one.x - two.x 147 | dy = one.y - two.y 148 | return math.sqrt(dx * dx + dy * dy) 149 | 150 | def get_xy_point_from_rgb(self, red, green, blue): 151 | """Returns an XYPoint object containing the closest available CIE 1931 x, y coordinates 152 | based on the RGB input values.""" 153 | 154 | r = ((red + 0.055) / (1.0 + 0.055))**2.4 if (red > 0.04045) else (red / 12.92) 155 | g = ((green + 0.055) / (1.0 + 0.055))**2.4 if (green > 0.04045) else (green / 12.92) 156 | b = ((blue + 0.055) / (1.0 + 0.055))**2.4 if (blue > 0.04045) else (blue / 12.92) 157 | 158 | X = r * 0.664511 + g * 0.154324 + b * 0.162028 159 | Y = r * 0.283881 + g * 0.668433 + b * 0.047685 160 | Z = r * 0.000088 + g * 0.072310 + b * 0.986039 161 | 162 | if(X == 0 and Y == 0 and Z == 0): 163 | cx = 0 164 | cy = 0 165 | else: 166 | cx = X / (X + Y + Z) 167 | cy = Y / (X + Y + Z) 168 | 169 | # Check if the given XY value is within the colourreach of our lamps. 170 | xy_point = XYPoint(cx, cy) 171 | in_reach = self.check_point_in_lamps_reach(xy_point) 172 | 173 | if not in_reach: 174 | xy_point = self.get_closest_point_to_point(xy_point) 175 | 176 | return xy_point 177 | 178 | def get_rgb_from_xy_and_brightness(self, x, y, bri=1): 179 | """Inverse of `get_xy_point_from_rgb`. Returns (r, g, b) for given x, y values. 180 | Implementation of the instructions found on the Philips Hue iOS SDK docs: http://goo.gl/kWKXKl 181 | """ 182 | # The xy to color conversion is almost the same, but in reverse order. 183 | # Check if the xy value is within the color gamut of the lamp. 184 | # If not continue with step 2, otherwise step 3. 185 | # We do this to calculate the most accurate color the given light can actually do. 186 | xy_point = XYPoint(x, y) 187 | 188 | if not self.check_point_in_lamps_reach(xy_point): 189 | # Calculate the closest point on the color gamut triangle 190 | # and use that as xy value See step 6 of color to xy. 191 | xy_point = self.get_closest_point_to_point(xy_point) 192 | 193 | # Calculate XYZ values Convert using the following formulas: 194 | Y = bri 195 | X = (Y / xy_point.y) * xy_point.x 196 | Z = (Y / xy_point.y) * (1 - xy_point.x - xy_point.y) 197 | 198 | # Convert to RGB using Wide RGB D65 conversion 199 | r = X * 1.656492 - Y * 0.354851 - Z * 0.255038 200 | g = -X * 0.707196 + Y * 1.655397 + Z * 0.036152 201 | b = X * 0.051713 - Y * 0.121364 + Z * 1.011530 202 | 203 | # Apply reverse gamma correction 204 | r, g, b = map( 205 | lambda x: (12.92 * x) if (x <= 0.0031308) else ((1.0 + 0.055) * pow(x, (1.0 / 2.4)) - 0.055), 206 | [r, g, b] 207 | ) 208 | 209 | # Bring all negative components to zero 210 | r, g, b = map(lambda x: max(0, x), [r, g, b]) 211 | 212 | # If one component is greater than 1, weight components by that value. 213 | max_component = max(r, g, b) 214 | if max_component > 1: 215 | r, g, b = map(lambda x: x / max_component, [r, g, b]) 216 | 217 | r, g, b = map(lambda x: int(x * 255), [r, g, b]) 218 | 219 | # Convert the RGB values to your color object The rgb values from the above formulas are between 0.0 and 1.0. 220 | return (r, g, b) 221 | 222 | 223 | class Converter: 224 | 225 | def __init__(self, gamut=GamutB): 226 | self.color = ColorHelper(gamut) 227 | 228 | def hex_to_xy(self, h): 229 | """Converts hexadecimal colors represented as a String to approximate CIE 230 | 1931 x and y coordinates. 231 | """ 232 | rgb = self.color.hex_to_rgb(h) 233 | return self.rgb_to_xy(rgb[0], rgb[1], rgb[2]) 234 | 235 | def rgb_to_xy(self, red, green, blue): 236 | """Converts red, green and blue integer values to approximate CIE 1931 237 | x and y coordinates. 238 | """ 239 | point = self.color.get_xy_point_from_rgb(red, green, blue) 240 | return (point.x, point.y) 241 | 242 | def xy_to_hex(self, x, y, bri=1): 243 | """Converts CIE 1931 x and y coordinates and brightness value from 0 to 1 244 | to a CSS hex color.""" 245 | r, g, b = self.color.get_rgb_from_xy_and_brightness(x, y, bri) 246 | return self.color.rgb_to_hex(r, g, b) 247 | 248 | def xy_to_rgb(self, x, y, bri=1): 249 | """Converts CIE 1931 x and y coordinates and brightness value from 0 to 1 250 | to a CSS hex color.""" 251 | r, g, b = self.color.get_rgb_from_xy_and_brightness(x, y, bri) 252 | return (r, g, b) 253 | 254 | def get_random_xy_color(self): 255 | """Returns the approximate CIE 1931 x,y coordinates represented by the 256 | supplied hexColor parameter, or of a random color if the parameter 257 | is not passed.""" 258 | r = self.color.random_rgb_value() 259 | g = self.color.random_rgb_value() 260 | b = self.color.random_rgb_value() 261 | return self.rgb_to_xy(r, g, b) 262 | -------------------------------------------------------------------------------- /discovery_lib.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | Helper for connecting to the bridge. If we don't 4 | have a valid username for the bridge (ip) we are trying 5 | to use, this will cause one to be generated. 6 | """ 7 | 8 | from __future__ import print_function 9 | import sys 10 | import json 11 | import requests 12 | 13 | 14 | # NUPNP adress 15 | HUE_NUPNP = "https://discovery.meethue.com" 16 | 17 | class IPError(Exception): 18 | ''' Raise when the Hue Bridge IP address cannot be resolved ''' 19 | 20 | 21 | class DiscoveryLib: 22 | def __init__(self): 23 | "init" 24 | 25 | ################ 26 | # HTTP METHODS # 27 | ################ 28 | 29 | # GET Request 30 | def get(self, url): 31 | response = requests.get(url, timeout = 10) 32 | return self.responseData(response) 33 | 34 | # PUT Request 35 | def put(self, url, payload): 36 | response = requests.put(url, data = json.dumps(payload)) 37 | return self.responseData(response) 38 | 39 | # POST Request 40 | def post(self, url, payload): 41 | response = requests.post(url, data = json.dumps(payload)) 42 | return self.responseData(response) 43 | 44 | 45 | ############# 46 | # HUE SETUP # 47 | ############# 48 | 49 | # Gets bridge IP using Hue's NUPNP site. Device must be on the same network as the bridge 50 | def getBridgeIP(self): 51 | try: 52 | return self.get(HUE_NUPNP)['json'][0]['internalipaddress'] 53 | except: 54 | raise IPError('Could not resolve Hue Bridge IP address. Please ensure your bridge is connected') 55 | 56 | # If given a brige IP as a constructor parameter, this validates it 57 | def validateIP(self, ip): 58 | try: 59 | data = self.get('http://{}/api/'.format(ip)) 60 | if not data['ok']: 61 | raise IPError('Invalid Hue Bridge IP address') 62 | except (requests.exceptions.ConnectionError, 63 | requests.exceptions.MissingSchema, 64 | requests.exceptions.ConnectTimeout): 65 | raise IPError('Invalid Hue Bridge IP address') 66 | 67 | return ip 68 | 69 | 70 | # Takes HTTP request response and returns pertinent information in a dict 71 | def responseData(self, response): 72 | data = {'status_code': response.status_code, 'ok': response.ok} 73 | if response.ok: 74 | data['json'] = response.json() 75 | return data 76 | 77 | ##################### 78 | # CUSTOM EXCEPTIONS # 79 | ##################### 80 | 81 | -------------------------------------------------------------------------------- /example/dark_or_bright_background.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/digital-concrete/light-sync/b2f8405971b6204f4d43f5a63ae91381462913f2/example/dark_or_bright_background.jpg -------------------------------------------------------------------------------- /example/hue_sync_app.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/digital-concrete/light-sync/b2f8405971b6204f4d43f5a63ae91381462913f2/example/hue_sync_app.jpg -------------------------------------------------------------------------------- /example/image_after_kmeans.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/digital-concrete/light-sync/b2f8405971b6204f4d43f5a63ae91381462913f2/example/image_after_kmeans.png -------------------------------------------------------------------------------- /example/light_sync_app.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/digital-concrete/light-sync/b2f8405971b6204f4d43f5a63ae91381462913f2/example/light_sync_app.jpg -------------------------------------------------------------------------------- /example/mask.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/digital-concrete/light-sync/b2f8405971b6204f4d43f5a63ae91381462913f2/example/mask.png -------------------------------------------------------------------------------- /example/masked_image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/digital-concrete/light-sync/b2f8405971b6204f4d43f5a63ae91381462913f2/example/masked_image.jpg -------------------------------------------------------------------------------- /example/test_image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/digital-concrete/light-sync/b2f8405971b6204f4d43f5a63ae91381462913f2/example/test_image.jpg -------------------------------------------------------------------------------- /frame_color_lib.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | Frame color related lib 4 | Helper methods and 5 | Most relevand color algorithm 6 | """ 7 | 8 | from __future__ import print_function 9 | import numpy 10 | import cv2 11 | import math 12 | 13 | from convertor_lib import Converter 14 | 15 | # Frame Color Definition 16 | class FrameColor(object): 17 | """__init__() functions as the class constructor""" 18 | 19 | def __init__(self, color, index, color_count, 20 | min_threshold, max_threshold, color_converter): 21 | self.color = color 22 | self.index = index 23 | self.color_count = color_count 24 | self.is_dark = False 25 | self.is_bright = False 26 | self.min_threshold = min_threshold 27 | self.max_threshold = max_threshold 28 | self.calculate_light_dark_channels() 29 | self.color_converter = color_converter 30 | self.brightness = None 31 | self.go_dark = None 32 | self.diff_from_prev = None 33 | 34 | def calculate_light_dark_channels(self): 35 | """Calculates whether color is bright or dark""" 36 | bright_channels_count = 0 37 | dark_channels_count = 0 38 | for channel in range(0, 3): 39 | if self.color[channel] > self.max_threshold: 40 | bright_channels_count += 1 41 | 42 | if self.color[channel] < self.min_threshold: 43 | 44 | dark_channels_count += 1 45 | 46 | if bright_channels_count == 3: 47 | self.is_bright = True 48 | 49 | if dark_channels_count == 3: 50 | self.is_dark = True 51 | 52 | def get_hue_color(self): 53 | """Return the color in Philips HUE XY format""" 54 | return self.color_converter.rgb_to_xy( 55 | self.color[2], self.color[1], self.color[0]) 56 | 57 | class FrameColorLib: 58 | def __init__(self): 59 | "init" 60 | self.color_converter = Converter() 61 | 62 | def shrink_image(self, input_img, input_image_reduced_size): 63 | "Reduce image size to increase computation speed" 64 | height, width = input_img.shape[:2] 65 | max_height = input_image_reduced_size 66 | max_witdh = input_image_reduced_size 67 | 68 | if max_height < height or max_witdh < width: 69 | # Get scaling factor 70 | scaling_factor = max_height / float(height) 71 | if max_witdh/float(width) < scaling_factor: 72 | scaling_factor = max_witdh / float(width) 73 | # Resize image. You can use INTER_AREA if you have a performant computer 74 | input_img = cv2.resize(input_img, None, fx=scaling_factor, 75 | fy=scaling_factor, interpolation=cv2.INTER_LINEAR) 76 | return input_img 77 | 78 | def apply_frame_mask(self, current_frame, channels_min_threshold): 79 | "Apply dark color threshold and compute mask" 80 | gray_frame = cv2.cvtColor(current_frame, cv2.COLOR_BGR2GRAY) 81 | ret, mask = cv2.threshold( 82 | gray_frame, channels_min_threshold, 255, cv2.THRESH_BINARY) 83 | 84 | # Apply mask to frame 85 | masked_frame = cv2.bitwise_and( 86 | current_frame, current_frame, mask=mask) 87 | 88 | return masked_frame 89 | 90 | def calculate_frame_brightness(self, frame, dim_brightness, starting_brightness, 91 | min_non_zero_count, max_non_zero_count): 92 | "Calculates frame brightness" 93 | 94 | # Actual non zero thresholds in pixels 95 | min_non_zero_count_pixels = frame.size * min_non_zero_count / 100 96 | max_non_zero_count_pixels = frame.size * max_non_zero_count / 100 97 | 98 | # Check the non zero pixels. If their count is too low turn the lights off 99 | gray_image_output = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) 100 | nz_count = cv2.countNonZero(gray_image_output) 101 | if nz_count < min_non_zero_count_pixels: 102 | current_brightness = dim_brightness - 1 103 | else: 104 | # If False go through all routines 105 | if nz_count > max_non_zero_count_pixels: 106 | current_brightness = starting_brightness 107 | else: 108 | current_brightness = (nz_count - min_non_zero_count_pixels) * ( 109 | starting_brightness - dim_brightness) / ( 110 | max_non_zero_count_pixels - min_non_zero_count_pixels) + ( 111 | dim_brightness) 112 | current_brightness = int(current_brightness) 113 | 114 | return current_brightness 115 | 116 | def frame_colors_are_similar(self, first_color, second_color, 117 | color_skip_sensitivity, 118 | brightness_skip_sensitivity): 119 | "checks if 2 frame colors are similar" 120 | result = False 121 | if first_color is not None and\ 122 | second_color is not None: 123 | if(first_color.go_dark == True and second_color.go_dark == True): 124 | return True 125 | 126 | if abs(first_color.brightness - second_color.brightness) < brightness_skip_sensitivity: 127 | for j in range(0, 3): 128 | ch_diff = math.fabs( 129 | int(first_color.color[j]) - int(second_color.color[j])) 130 | if ch_diff < color_skip_sensitivity: 131 | result = True 132 | break 133 | return result 134 | 135 | def calculate_hue_color(self, input_img, k_means, 136 | color_spread_threshold, 137 | channels_min_threshold, 138 | channels_max_threshold): 139 | "Calculates the color to be sent to HUE" 140 | # COMPUTE K MEANS 141 | k_means_input_img = input_img.reshape((-1, 4)) 142 | 143 | # Convert to np.float32 144 | k_means_input_img = numpy.float32(k_means_input_img) 145 | 146 | # Define criteria, number of clusters(K) and apply kmeans() 147 | criteria = (cv2.TERM_CRITERIA_EPS + 148 | cv2.TERM_CRITERIA_MAX_ITER, 10, 1.0) 149 | k = k_means 150 | ret, label, center = cv2.kmeans( 151 | k_means_input_img, k, None, criteria, 10, cv2.KMEANS_RANDOM_CENTERS) 152 | 153 | # Now convert back into uint8, and make original image 154 | center = numpy.uint8(center) 155 | 156 | # COMPUTE MOST PREVALENT CLUSTER 157 | 158 | # Calculate the prevalence for each one of the resulted colors 159 | label_counts = [] 160 | for j in range(k_means): 161 | label_counts.append(0) 162 | for j in range(0, label.size): 163 | label_counts[label[j][0]] += 1 164 | 165 | # Init and populate Array of FrameColor objects for further calculations/decisions 166 | frame_colors = [] 167 | 168 | for j in range(k_means): 169 | frame_color = FrameColor(center[j], j, label_counts[j], 170 | channels_min_threshold, channels_max_threshold, self.color_converter) 171 | frame_colors.append(frame_color) 172 | 173 | # Sort by prevalence 174 | frame_colors.sort(key=lambda x: x.color_count, reverse=True) 175 | 176 | # Calculate color to be sent to Hue 177 | result_color = frame_colors[0] 178 | 179 | if frame_colors[0].is_bright: 180 | for j in range(1, k_means): 181 | if (not frame_colors[j].is_bright 182 | and not frame_colors[j].is_dark 183 | and (frame_colors[j].color_count / label.size) * 100 184 | > color_spread_threshold): 185 | result_color = frame_colors[j] 186 | break 187 | 188 | if frame_colors[0].is_dark: 189 | for j in range(1, k_means): 190 | if not frame_colors[j].is_dark: 191 | result_color = frame_colors[j] 192 | break 193 | 194 | return result_color 195 | 196 | -------------------------------------------------------------------------------- /light_sync.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | Script for synchronizing Philips Hue lights with computer display colors. 4 | 5 | Based on: 6 | 7 | http://python-mss.readthedocs.io/examples.html 8 | 9 | https://docs.opencv.org/3.0-beta/doc/py_tutorials/py_ml/py_kmeans/py_kmeans_opencv/py_kmeans_opencv.html 10 | 11 | https://www.developers.meethue.com/ 12 | 13 | https://github.com/studioimaginaire/phue 14 | 15 | https://github.com/benknight/hue-python-rgb-converter 16 | 17 | Hue Personal Wireless Lighting is a trademark owned by Philips Electronics 18 | See www.meethue.com for more information. 19 | I am in no way affiliated with the Philips organization. 20 | 21 | Published under the MIT license - See LICENSE file for more details. 22 | 23 | Copyright (c) 2018 DIGITAL CONCRETE JUNGLE / MIT License. 24 | """ 25 | 26 | from __future__ import division 27 | from __future__ import print_function 28 | 29 | import sys 30 | import os 31 | import argparse 32 | import math 33 | import time 34 | import threading 35 | import numpy 36 | import cv2 37 | import mss 38 | import requests 39 | import json 40 | 41 | from phue_lib import Bridge, PhueRegistrationException 42 | from discovery_lib import DiscoveryLib 43 | from frame_color_lib import FrameColorLib 44 | 45 | # SETUP VARIABLES (tweak them for preffered effect) 46 | 47 | # Your Philips Hue Bridge IP 48 | BRIDGE_IP = '192.168.1.22' 49 | 50 | # Part of screen to capture (useful if run in multiple instance for custom effects like stereo) 51 | # full, left, right, side-left side-right 52 | SCREEN_PART_TO_CAPTURE = "full" 53 | 54 | # Your Philips HUE lights that will be updated by this script 55 | MY_LIGHT_NAMES = ['Light1','Light2'] 56 | 57 | # IDS of Your Philips HUE lights that will be updated by this script 58 | MY_LIGHT_IDS = [] 59 | 60 | # Dim lights instead of turn off 61 | DIM_LIGHTS_INSTEAD_OF_TURN_OFF = False 62 | 63 | # MAX brightness 64 | STARTING_BRIGHTNESS = 110 65 | 66 | # Dim brightness 67 | DIM_BRIGHTNESS = 3 68 | 69 | # Default color to be used when RGB are all 0 70 | DEFAULT_DARK_COLOR = [64, 75, 78] 71 | 72 | # Skip frame if brightness difference is less than this 73 | BRIGHTNESS_SKIP_SENSITIVITY = 10 74 | 75 | # Transition type: 76 | # 0 = instant 77 | # 1 = instant if frame diff is over threshold and smooth if frames are similar 78 | # 2 = smooth 79 | TRANSITION_TYPE = 0 80 | 81 | # Transition time for smooth transitions 82 | TRANSITION_TIME = 1 83 | 84 | # Max number of Hue update requests per second. Used to prevent Hue Bridge bottleneck 85 | HUE_MAX_REQUESTS_PER_SECOND = 10 86 | 87 | # Use to skip similar frames in a row 88 | FRAME_MATCH_SENSITIVITY = 0.008 89 | 90 | # BETA version!!! 91 | # Use to transition smoothly when frames are less than this value and 92 | # greater than FRAME_MATCH_SENSITIVITY 93 | FRAME_MATCH_SMOOTH_TRANSITION_SENSITIVITY = 0.1 94 | 95 | # Resulted colors from previous and current frame are compared channel by channel 96 | # If there is no difference bigger than COLOR_SKIP_SENSITIVITY between any of the channels 97 | # then frame is skipped 98 | COLOR_SKIP_SENSITIVITY = 10 99 | 100 | # If color channel values are below or above these values they are considered to be dark or bright 101 | # When most of the screen is dark or bright then the next available color is considered 102 | CHANNELS_MIN_THRESHOLD = 50 103 | CHANNELS_MAX_THRESHOLD = 190 104 | 105 | # MIN NON ZERO COUNT 106 | MIN_NON_ZERO_COUNT = 0.2 107 | 108 | # TOP NON ZERO COUNT THRESHOLD 109 | MAX_NON_ZERO_COUNT = 20 110 | 111 | # Min color spread threshold 112 | COLOR_SPREAD_THRESHOLD = 0.4 113 | 114 | # Number of clusters computed by the OpenCV K-MEANS algorithm 115 | NUMBER_OF_K_MEANS_CLUSTERS = 6 116 | 117 | # Captured screen can have a very large amount of data which takes longer time to process 118 | # by the K Means algorithm. 119 | # Image will be scaled to a much smaller size resulting in real time updating of the lights 120 | INPUT_IMAGE_REDUCED_SIZE = 100 121 | 122 | # PHUE config file name 123 | PHUE_CONFIG_FILE = "phue_config" 124 | 125 | # BETA version!!! 126 | # Auto adjust performance 127 | AUTO_ADJUST_PERFORMANCE = True 128 | 129 | # Auto adjust performance after number of low fps in a row 130 | AUTO_ADJUST_PERFORMANCE_NUMBER_OF_LOW_FPS_IN_A_ROW = 2 131 | 132 | # BETA version!!! 133 | # Flicker prevent 134 | FLICKER_PREVENT = True 135 | 136 | # Number of frames in buffer 137 | FLICKER_PREVENT_BUFFER_SIZE = 20 138 | 139 | # Number of frames to check for color flicker 140 | COLOR_FLICKER_PREVENT_REQUIRED_INPUT_FRAMES_COUNT = 4 141 | 142 | # How many frames satisfy the conditions in order to detect color flicker 143 | COLOR_FLIKCER_DETECTION_THRESHOLD = 2 144 | 145 | # Number of frames to check for on/off flicker 146 | ON_OFF_FLICKER_PREVENT_REQUIRED_INPUT_FRAMES_COUNT = 8 147 | 148 | # How many frames satisfy the conditions in order to detect on/off flicker 149 | ON_OFF_FLIKCER_DETECTION_THRESHOLD = 2 150 | 151 | # Number of frames with correction until reset 152 | FLICKER_CORRECTION_FRAME_COUNT_UNTIL_RESET = 30 153 | 154 | # Flicker reset sensitivity 155 | # If tho consecutive frames have a greater match diff, 156 | # then reset the adjustments 157 | FLICKER_CORRECTION_RESET_SENSITIVITY = 0.3 158 | 159 | # Color Flicker sensitivity 160 | # Color flicker is detected if frame match is lower than this 161 | # and result colors are different 162 | COLOR_FLICKER_FRAME_MATCH_THRESHOLD = 0.2 163 | 164 | # ON OFF FLICKER CORRECTION 165 | ON_OFF_FLICKER_MIN_NON_ZERO_COUNT_CORRECTION_VALUE = 0.1 166 | 167 | # ON OFF FLICKER CORRECTION 168 | ON_OFF_FLICKER_MIN_THRESHOLD_FLICKER_CORRECTION_VALUE = 0.2 169 | 170 | # GLOBALS 171 | 172 | # CAN UPDATE HUE FLAG 173 | CAN_UPDATE_HUE = True 174 | 175 | # Init convertor lib used to format color for HUE 176 | DISCOVERY_LIB = DiscoveryLib() 177 | FRAME_COLOR_LIB = FrameColorLib() 178 | 179 | def clear_update_flag(): 180 | """Clears the hue update request lock""" 181 | # can_update_hue 182 | global CAN_UPDATE_HUE 183 | CAN_UPDATE_HUE = True 184 | 185 | def usage(parser): 186 | """Help""" 187 | parser.print_help() 188 | 189 | print ("Example:") 190 | print ("\t" + sys.argv[0] + 191 | " --bridgeip 192.168.1.23 --lights Light1,Light2") 192 | sys.exit() 193 | 194 | def main(argv): 195 | "main routine" 196 | 197 | # Variable Defaults 198 | bridge_ip = BRIDGE_IP 199 | user = None 200 | screen_part_to_capture = SCREEN_PART_TO_CAPTURE 201 | my_light_names = MY_LIGHT_NAMES 202 | my_light_ids = MY_LIGHT_IDS 203 | dim_brightness = DIM_BRIGHTNESS 204 | starting_brightness = STARTING_BRIGHTNESS 205 | dim_lights_instead_of_turn_off = DIM_LIGHTS_INSTEAD_OF_TURN_OFF 206 | transition_time = TRANSITION_TIME 207 | transition_type = TRANSITION_TYPE 208 | frame_transition_sensitivity = FRAME_MATCH_SMOOTH_TRANSITION_SENSITIVITY 209 | frame_match_sensitivity = FRAME_MATCH_SENSITIVITY 210 | color_skip_sensitivity = COLOR_SKIP_SENSITIVITY 211 | brightness_skip_sensitivity = BRIGHTNESS_SKIP_SENSITIVITY 212 | channels_min_threshold = CHANNELS_MIN_THRESHOLD 213 | channels_max_threshold = CHANNELS_MAX_THRESHOLD 214 | min_non_zero_count = MIN_NON_ZERO_COUNT 215 | max_non_zero_count = MAX_NON_ZERO_COUNT 216 | color_spread_threshold = COLOR_SPREAD_THRESHOLD 217 | number_of_k_means_clusters = NUMBER_OF_K_MEANS_CLUSTERS 218 | input_image_reduced_size = INPUT_IMAGE_REDUCED_SIZE 219 | hue_max_requests_per_second = HUE_MAX_REQUESTS_PER_SECOND 220 | 221 | # Auto adjust performance 222 | auto_adjust_performance = AUTO_ADJUST_PERFORMANCE 223 | adjust_counter_limit = AUTO_ADJUST_PERFORMANCE_NUMBER_OF_LOW_FPS_IN_A_ROW 224 | 225 | # FLICKER related 226 | flicker_prevent = FLICKER_PREVENT 227 | result_buffer_size = FLICKER_PREVENT_BUFFER_SIZE 228 | flicker_correction_frame_count_until_reset = FLICKER_CORRECTION_FRAME_COUNT_UNTIL_RESET 229 | flicker_correction_reset_sensitivity = FLICKER_CORRECTION_RESET_SENSITIVITY 230 | color_flicker_frame_match_threshold = COLOR_FLICKER_FRAME_MATCH_THRESHOLD 231 | color_flicker_prevent_required_input_frames_count = COLOR_FLICKER_PREVENT_REQUIRED_INPUT_FRAMES_COUNT 232 | on_off_flicker_prevent_required_input_frames_count = ON_OFF_FLICKER_PREVENT_REQUIRED_INPUT_FRAMES_COUNT 233 | color_flicker_count_threshold = COLOR_FLIKCER_DETECTION_THRESHOLD 234 | on_off_flicker_count_threshold = ON_OFF_FLIKCER_DETECTION_THRESHOLD 235 | min_non_zero_count_on_off_flicker_correction_value = ON_OFF_FLICKER_MIN_NON_ZERO_COUNT_CORRECTION_VALUE 236 | channels_min_threshold_on_off_flicker_correction_value = ON_OFF_FLICKER_MIN_THRESHOLD_FLICKER_CORRECTION_VALUE 237 | 238 | # Arguments or defaults 239 | parser = argparse.ArgumentParser(description="Sync Hue Lights with computer display") 240 | parser.add_argument("-i", "--bridgeip", help="Your Philips Hue Bridge IP") 241 | parser.add_argument("-u", "--user", help="Your Philips Hue Bridge User") 242 | parser.add_argument("-p", "--screenpart", help="Part of the screen to capture: full, left, right, \ 243 | side-left, side-right (default full)") 244 | parser.add_argument("-l", "--lightids", help="Your Philips HUE light Ids that will be updated, comma separated") 245 | parser.add_argument("-L", "--lights", help="Your Philips HUE light Names that will be updated, comma separated") 246 | parser.add_argument("-b", "--dimbrightness", help="Dim/MIN brightness [0-256] must be less than maxbrightness") 247 | parser.add_argument("-B", "--maxbrightness", help="MAX brightness [0-256]") 248 | parser.add_argument("-D", "--dimlightsinsteadofturnoff", help="Dim lights or Turn OFF on dark screens (default true - DIM)") 249 | parser.add_argument("-t", "--transitiontime", help="Transition time, default 1") 250 | parser.add_argument("-y", "--transitiontype", help="Transition type, default 1 (0 = instant, 1 = comfortable, 2 = smooth)") 251 | parser.add_argument("-v", "--frametransitionsensitivity", help="Smooth transition sensitivity for comfortable mode, [0-1] default 0.5.\ 252 | Frame difference between framematchsensitivity and frametransitionsensitivity\ 253 | will have a transition time. Otherwise transition will be instant") 254 | parser.add_argument("-s", "--framematchsensitivity", help="Use to skip similar frames in a row [0-1] default: 0.008") 255 | parser.add_argument("-S", "--colorskipsensitivity", help="Skip frame if color is similar [0-256], default 10") 256 | parser.add_argument("-g", "--brightnessskipsensitivity", help="Skip frame if brightness diff is less [0-256], default 10") 257 | parser.add_argument("-c", "--channelsminthreshold", help="Dark threshold [0-256], default 50") 258 | parser.add_argument("-C", "--channelsmaxthreshold", help="Bright threshold [0-25]6, default 190, > minthreshold") 259 | parser.add_argument("-m", "--minnzcount", help="Min non zero threshold [0-100], default 0.2") 260 | parser.add_argument("-M", "--maxnzcount", help="Top non zero threshold [1-100], default 20, > minthreshold") 261 | parser.add_argument("-d", "--colorspreadthreshold", help="Color spread threshold [0-100], default 0.005") 262 | parser.add_argument("-k", "--kmeansclusters", help="Number of clusters computed by the OpenCV K-MEANS algorithm") 263 | parser.add_argument("-z", "--shrinkframesize", help="Frame capture shrinked size in pixel for better performance (default 100)") 264 | parser.add_argument("-T", "--maxrequestspersecond", help="Max requests per second sent to bridge api (default 10)") 265 | parser.add_argument("-a", "--autodiscovery", help="Bridge auto discovery on LAN") 266 | 267 | # Auto adjust performance 268 | parser.add_argument("-A", "--autoadjustperformance", help="Auto adjust script performance (default True)") 269 | parser.add_argument("-Af", "--autoadjustperformancelowfpscount", help="Number of low fps in a row to trigger performance adjust (default 2)") 270 | 271 | # FLICKER args 272 | parser.add_argument("-f", "--flickercorrection", help="Enable flicker correction (default True)") 273 | parser.add_argument("-fb", "--flickerframebuffersize", help="Flicker prevent result buffer size (default 20)") 274 | parser.add_argument("-fr", "--flickerresetcounter", help="Flicker correction frame count until reset (default 30)") 275 | parser.add_argument("-fs", "--flickerresetsensitivity", help="Flicker correction reset sensitivity (default 0.3)") 276 | parser.add_argument("-fcs", "--colorflickersensitivity", help="Color flicker frame match threshold (default 0.2)") 277 | parser.add_argument("-fci", "--colorflickerinput", help="Color flicker detection required input frames count (default 4)") 278 | parser.add_argument("-foi", "--onoffflickerinput", help="On/Off flicker detection required input frames count (default 8)") 279 | parser.add_argument("-fcc", "--colorflickercount", help="Number of frames that match color flicker condition required to trigger correction (default 2)") 280 | parser.add_argument("-foc", "--onoffflickercount", help="Number of frames that match on/off flicker condition required to trigger correction (default 2)") 281 | parser.add_argument("-fonz", "--onoffflickerminnzcorrection", help="On/Off flicker min nz count correction (default 0.1)") 282 | parser.add_argument("-focm", "--onoffflickerchannelsmincorrection", help="On/Off flicker channels min threshold correction (default 0.2)") 283 | 284 | args = parser.parse_args() 285 | 286 | if args.bridgeip: 287 | bridge_ip = args.bridgeip 288 | print ("Set bridge ip: " + bridge_ip) 289 | if args.user: 290 | user = args.user 291 | print ("Set User: " + user) 292 | if args.screenpart: 293 | screen_part_to_capture = args.screenpart 294 | print ("Set screen part to capture: " + screen_part_to_capture) 295 | if args.autodiscovery: 296 | bridge_ip = DISCOVERY_LIB.getBridgeIP() 297 | print ("Discovered bridge ip: " + bridge_ip) 298 | if args.lightids: 299 | my_light_ids = args.lightids.split(",") 300 | print ("Set lights: " + ", ".join(my_light_ids)) 301 | if args.lights: 302 | my_light_names = args.lights.split(",") 303 | print ("Set lights: " + ", ".join(my_light_names)) 304 | if args.dimbrightness: 305 | try: 306 | dim_brightness = int(args.dimbrightness) 307 | print ("Set min/dim brightness: " + str(dim_brightness)) 308 | except ValueError: 309 | print ("dimbrightness must be a number\n") 310 | usage(parser) 311 | if args.maxbrightness: 312 | try: 313 | starting_brightness = int(args.maxbrightness) 314 | print ("Set max brightness: " + str(dim_brightness)) 315 | except ValueError: 316 | print ("maxbrightness must be a number\n") 317 | usage(parser) 318 | if args.dimlightsinsteadofturnoff: 319 | try: 320 | ua = str(args.dimlightsinsteadofturnoff).upper() 321 | if 'TRUE'.startswith(ua): 322 | dim_lights_instead_of_turn_off = True 323 | elif 'FALSE'.startswith(ua): 324 | dim_lights_instead_of_turn_off = False 325 | else: 326 | raise ValueError("dimlightsinsteadofturnoff must be a boolean\n") 327 | print ("Set dim lights or turn off: " + str(dim_lights_instead_of_turn_off)) 328 | except ValueError: 329 | print ("dimlightsinsteadofturnoff must be a boolean\n") 330 | usage(parser) 331 | if args.transitiontime: 332 | try: 333 | transition_time = int(args.transitiontime) 334 | print ("Set transition time: " + str(transition_time)) 335 | except ValueError: 336 | print ("transitiontime must be a number\n") 337 | usage(parser) 338 | if args.transitiontime: 339 | try: 340 | transition_type = int(args.transitiontime) 341 | print ("Set transition type: " + str(transition_type)) 342 | print ("0 = instant, 1 = comfortable, 2 = smooth") 343 | except ValueError: 344 | print ("transitiontime must be a number\n") 345 | usage(parser) 346 | if args.frametransitionsensitivity: 347 | try: 348 | frame_transition_sensitivity = float(args.frametransitionsensitivity) 349 | print ("Set frame transistion sensitivity: " + 350 | str(frame_transition_sensitivity)) 351 | except ValueError: 352 | print ("frametransitionsensitivity must be a number\n") 353 | usage(parser) 354 | if args.framematchsensitivity: 355 | try: 356 | frame_match_sensitivity = float(args.framematchsensitivity) 357 | print ("Set frame match sensitivity: " + 358 | str(frame_match_sensitivity)) 359 | except ValueError: 360 | print ("framematchsensitivity must be a number\n") 361 | usage(parser) 362 | if args.colorskipsensitivity: 363 | try: 364 | color_skip_sensitivity = float(args.colorskipsensitivity) 365 | print ("Set color skip sensitivity: " + 366 | str(color_skip_sensitivity)) 367 | except ValueError: 368 | print ("colorskipsensitivity must be a number\n") 369 | usage(parser) 370 | if args.brightnessskipsensitivity: 371 | try: 372 | brightness_skip_sensitivity = float(args.brightnessskipsensitivity) 373 | print ("Set brightness skip sensitivity: " + 374 | str(brightness_skip_sensitivity)) 375 | except ValueError: 376 | print ("brightnessskipsensitivity must be a number\n") 377 | usage(parser) 378 | if args.channelsminthreshold: 379 | try: 380 | channels_min_threshold = int(args.channelsminthreshold) 381 | print ("Set channels min threshold: " + 382 | str(channels_min_threshold)) 383 | except ValueError: 384 | print ("channelsminthreshold must be a number\n") 385 | usage(parser) 386 | if args.channelsmaxthreshold: 387 | try: 388 | channels_max_threshold = int(args.channelsmaxthreshold) 389 | print ("Set channels max threshold: " + 390 | str(channels_max_threshold)) 391 | except ValueError: 392 | print ("channelsmaxthreshold must be a number\n") 393 | usage(parser) 394 | if args.minnzcount: 395 | try: 396 | min_non_zero_count = float(args.minnzcount) 397 | print ("Set min nz count: " + str(min_non_zero_count)) 398 | except ValueError: 399 | print ("minnzcount must be a number\n") 400 | usage(parser) 401 | if args.maxnzcount: 402 | try: 403 | max_non_zero_count = float(args.maxnzcount) 404 | print ("Set max nz count: " + str(max_non_zero_count)) 405 | except ValueError: 406 | print ("maxnzcount must be a number\n") 407 | usage(parser) 408 | if args.colorspreadthreshold: 409 | try: 410 | color_spread_threshold = float(args.colorspreadthreshold) 411 | print ("Set color spread: " + 412 | str(color_spread_threshold)) 413 | except ValueError: 414 | print ("colorspreadthreshold must be a number\n") 415 | usage(parser) 416 | if args.kmeansclusters: 417 | try: 418 | number_of_k_means_clusters = int(args.kmeansclusters) 419 | print ("Set no of k means clusters: " + 420 | str(number_of_k_means_clusters)) 421 | except ValueError: 422 | print ("kmeansclusters must be a number\n") 423 | usage(parser) 424 | if args.shrinkframesize: 425 | try: 426 | input_image_reduced_size = int(args.shrinkframesize) 427 | print ("Set shrinked frame size: " + 428 | str(input_image_reduced_size)) 429 | except ValueError: 430 | print ("shrinkframesize must be a number\n") 431 | usage(parser) 432 | if args.maxrequestspersecond: 433 | try: 434 | hue_max_requests_per_second = int(args.maxrequestspersecond) 435 | print ("Set max requests per second: " + 436 | str(hue_max_requests_per_second)) 437 | except ValueError: 438 | print ("maxrequestspersecond must be a number\n") 439 | usage(parser) 440 | 441 | # Auto adjust performance args 442 | if args.autoadjustperformance: 443 | try: 444 | aa = str(args.autoadjustperformance).upper() 445 | if 'TRUE'.startswith(aa): 446 | auto_adjust_performance = True 447 | elif 'FALSE'.startswith(aa): 448 | auto_adjust_performance = False 449 | else: 450 | raise ValueError("autoadjustperformance must be a boolean\n") 451 | print ("Set auto adjust performance: " + str(auto_adjust_performance)) 452 | except ValueError: 453 | print ("autoadjustperformance must be a boolean\n") 454 | usage(parser) 455 | if args.autoadjustperformancelowfpscount: 456 | try: 457 | adjust_counter_limit = int(args.autoadjustperformancelowfpscount) 458 | print ("Set auto adjust performance low fps count: " + 459 | str(adjust_counter_limit)) 460 | except ValueError: 461 | print ("autoadjustperformancelowfpscount must be a number\n") 462 | usage(parser) 463 | 464 | # FLICKER args 465 | if args.flickercorrection: 466 | try: 467 | aa = str(args.flickercorrection).upper() 468 | if 'TRUE'.startswith(aa): 469 | flicker_prevent = True 470 | elif 'FALSE'.startswith(aa): 471 | flicker_prevent = False 472 | else: 473 | raise ValueError("flickercorrection must be a boolean\n") 474 | print ("Set flicker prevent: " + str(flicker_prevent)) 475 | except ValueError: 476 | print ("flickercorrection must be a boolean\n") 477 | usage(parser) 478 | 479 | if args.flickerframebuffersize: 480 | try: 481 | result_buffer_size = int(args.flickerframebuffersize) 482 | print ("Set flicker prevent result buffer size : " + 483 | str(result_buffer_size)) 484 | except ValueError: 485 | print ("flickerframebuffersize must be a number\n") 486 | usage(parser) 487 | 488 | if args.flickerresetcounter: 489 | try: 490 | flicker_correction_frame_count_until_reset = int(args.flickerresetcounter) 491 | print ("Set flicker correction frame count until reset : " + 492 | str(flicker_correction_frame_count_until_reset)) 493 | except ValueError: 494 | print ("flickerresetcounter must be a number\n") 495 | usage(parser) 496 | 497 | if args.flickerresetsensitivity: 498 | try: 499 | flicker_correction_reset_sensitivity = float(args.flickerresetsensitivity) 500 | print ("Set flicker correction reset sensitivity : " + 501 | str(flicker_correction_reset_sensitivity)) 502 | except ValueError: 503 | print ("flickerresetsensitivity must be a number\n") 504 | usage(parser) 505 | 506 | if args.colorflickersensitivity: 507 | try: 508 | color_flicker_frame_match_threshold = float(args.colorflickersensitivity) 509 | print ("Set color flicker frame match threshold : " + 510 | str(color_flicker_frame_match_threshold)) 511 | except ValueError: 512 | print ("colorflickersensitivity must be a number\n") 513 | usage(parser) 514 | 515 | if args.colorflickerinput: 516 | try: 517 | color_flicker_prevent_required_input_frames_count = int(args.colorflickerinput) 518 | print ("Set color flicker detection required input frames count : " + 519 | str(color_flicker_prevent_required_input_frames_count)) 520 | except ValueError: 521 | print ("colorflickerinput must be a number\n") 522 | usage(parser) 523 | 524 | if args.onoffflickerinput: 525 | try: 526 | on_off_flicker_prevent_required_input_frames_count = int(args.onoffflickerinput) 527 | print ("Set On/Off flicker detection required input frames count : " + 528 | str(on_off_flicker_prevent_required_input_frames_count)) 529 | except ValueError: 530 | print ("onoffflickerinput must be a number\n") 531 | usage(parser) 532 | 533 | if args.colorflickercount: 534 | try: 535 | color_flicker_count_threshold = int(args.colorflickercount) 536 | print ("Set number of frames that match color flicker condition required to trigger correction : " + 537 | str(color_flicker_count_threshold)) 538 | except ValueError: 539 | print ("colorflickercount must be a number\n") 540 | usage(parser) 541 | 542 | if args.onoffflickercount: 543 | try: 544 | on_off_flicker_count_threshold = int(args.onoffflickercount) 545 | print ("Set number of frames that match on/off flicker condition required to trigger correction : " + 546 | str(on_off_flicker_count_threshold)) 547 | except ValueError: 548 | print ("onoffflickercount must be a number\n") 549 | usage(parser) 550 | 551 | if args.onoffflickerminnzcorrection: 552 | try: 553 | min_non_zero_count_on_off_flicker_correction_value = float(args.onoffflickerminnzcorrection) 554 | print ("Set On/Off flicker min nz count correction : " + 555 | str(min_non_zero_count_on_off_flicker_correction_value)) 556 | except ValueError: 557 | print ("onoffflickerminnzcorrection must be a number\n") 558 | usage(parser) 559 | 560 | if args.onoffflickerchannelsmincorrection: 561 | try: 562 | channels_min_threshold_on_off_flicker_correction_value = float(args.onoffflickerchannelsmincorrection) 563 | print ("Set On/Off flicker channels min threshold correction : " + 564 | str(channels_min_threshold_on_off_flicker_correction_value)) 565 | except ValueError: 566 | print ("onoffflickerchannelsmincorrection must be a number\n") 567 | usage(parser) 568 | 569 | 570 | # args validation 571 | if dim_brightness >= starting_brightness: 572 | print ('dimbrightness must be smaller than maxbrightness') 573 | usage(parser) 574 | if transition_type != 0 and transition_type != 1 and transition_type != 2: 575 | print ('transitiontype must be 0,1 or 2') 576 | usage(parser) 577 | if channels_min_threshold >= channels_max_threshold: 578 | print ('channelsminthreshold must be smaller than channelsmaxthreshold') 579 | usage(parser) 580 | if min_non_zero_count >= max_non_zero_count: 581 | print ('minnzcount must be smaller than maxnzcount') 582 | usage(parser) 583 | if min_non_zero_count > 100 \ 584 | or min_non_zero_count < 0 \ 585 | or max_non_zero_count < 0 \ 586 | or max_non_zero_count > 100: 587 | print ('nz count value must be in interval [0, 100]') 588 | usage(parser) 589 | if dim_brightness < 0 or dim_brightness > 256 \ 590 | or starting_brightness < 0 or starting_brightness > 256 \ 591 | or color_skip_sensitivity < 0 or color_skip_sensitivity > 256 \ 592 | or brightness_skip_sensitivity < 0 or brightness_skip_sensitivity > 256 \ 593 | or channels_min_threshold < 0 or channels_min_threshold > 256 \ 594 | or channels_max_threshold < 0 or channels_max_threshold > 256: 595 | print ('dimbrightness, maxbrightness, colorskipsensitivity\ 596 | channelsminthreshold, channelsmaxthreshold values must be in interval [0, 256]') 597 | usage(parser) 598 | 599 | # LIGHTS VALIDATION 600 | number_of_lights = len(my_light_ids) | len(my_light_names) 601 | if (number_of_lights == 0): 602 | print ('Please select at least one light.') 603 | usage(parser) 604 | 605 | if not dim_lights_instead_of_turn_off: 606 | dim_brightness = 3 607 | 608 | # Variables 609 | go_dark = False 610 | current_brightness = starting_brightness 611 | current_transition_time = 0 612 | if transition_type == 2: 613 | current_transition_time = transition_time 614 | prev_color = None 615 | prev_frame = None 616 | prev_brightness = None 617 | prev_fps = None 618 | 619 | # Auto adjust performance vars 620 | adjust_position = 0 621 | adjust_counter = 0 622 | request_timeout = 1/hue_max_requests_per_second * number_of_lights 623 | 624 | # FLICKER prevent vars 625 | result_buffer = [] 626 | color_flicker_countdown = 0 627 | on_off_flicker_countdown = 0 628 | 629 | # FLICKER correction defaults 630 | min_non_zero_count_flicker_correction = 1 631 | channels_min_threshold_flicker_correction = 1 632 | k_means_flicker_correction = 0 633 | 634 | global CAN_UPDATE_HUE 635 | 636 | # If the app is not registered and the button is not pressed, 637 | # press the button and call connect() (this only needs to be run a single time) 638 | 639 | # Your bridge IP 640 | connected = False 641 | shown_instructions = False 642 | current_dir = os.path.dirname(__file__) 643 | phue_config_file = os.path.join(current_dir, PHUE_CONFIG_FILE) 644 | bridge_ip = DISCOVERY_LIB.getBridgeIP() 645 | print ("Discovered bridge ip: " + bridge_ip) 646 | print('Connecting to bridge') 647 | i = 0 648 | while i < 30: 649 | time.sleep(1) 650 | try: 651 | bridge = Bridge(bridge_ip, None, phue_config_file) 652 | except PhueRegistrationException: 653 | if not shown_instructions: 654 | print('Press the Hue Bridge button in order to register') 655 | shown_instructions = True 656 | i += 1 657 | continue 658 | else: 659 | connected = True 660 | break 661 | if not connected: 662 | print('Failed to register to bridge') 663 | sys.exit() 664 | 665 | bridge.connect() 666 | 667 | print ('Connected to Hue Bridge with address {0}'.format(bridge_ip)) 668 | 669 | if (user == None): 670 | user = bridge.username 671 | 672 | light_names = bridge.get_light_objects('name') 673 | 674 | # Init lights 675 | if not my_light_ids: 676 | for hue_light in my_light_names: 677 | light_names[hue_light].on = True 678 | light_names[hue_light].brightness = starting_brightness 679 | my_light_ids.append(light_names[hue_light].light_id) 680 | else: 681 | for hue_light_id in my_light_ids: 682 | bridge.set_light(hue_light_id, 'on', True) 683 | bridge.set_light(hue_light_id, 'bri', starting_brightness) 684 | 685 | with mss.mss() as sct: 686 | # Part of the screen to capture (use if you want to create a multiple color effect) 687 | full_mon=sct.monitors[1] 688 | monitor = full_mon 689 | 690 | if screen_part_to_capture == "full": 691 | monitor = full_mon 692 | elif screen_part_to_capture == "left": 693 | half_mon_width = int(full_mon["width"]/2) 694 | monitor = {'top': 0, 'left': 0, 'width': half_mon_width, 'height': full_mon["height"]} 695 | elif screen_part_to_capture == "right": 696 | half_mon_width = int(full_mon["width"]/2) 697 | monitor = {'top': 0, 'left': half_mon_width, 'width': half_mon_width, 'height': full_mon["height"]} 698 | elif screen_part_to_capture == "side-left": 699 | side_mon_width = int(full_mon["width"]/4) 700 | monitor = {'top': 0, 'left': 0, 'width': side_mon_width, 'height': full_mon["height"]} 701 | elif screen_part_to_capture == "side-right": 702 | side_mon_width = int(full_mon["width"]/4) 703 | side_mon_right_offset = full_mon["width"] - side_mon_width 704 | monitor = {'top': 0, 'left': side_mon_right_offset, 'width': side_mon_width, 'height': full_mon["height"]} 705 | 706 | 707 | 708 | while 'Screen capturing': 709 | 710 | last_time = time.time() 711 | 712 | # Get raw pixels from the screen, save it to a Numpy array 713 | img = numpy.array(sct.grab(monitor)) 714 | 715 | # Shrink image for performance sake 716 | current_frame = FRAME_COLOR_LIB.shrink_image(img, input_image_reduced_size) 717 | 718 | # init frame comparrison result 719 | comparison_result = None 720 | 721 | # Compare Frame with Prev Frame 722 | # Skip if similar 723 | if prev_frame is not None: 724 | 725 | comparison_result = cv2.matchTemplate( 726 | current_frame, prev_frame, 1) 727 | if comparison_result[0][0] < frame_match_sensitivity: 728 | continue 729 | 730 | #transition stuff 731 | elif transition_type == 1: 732 | if comparison_result[0][0] < frame_transition_sensitivity: 733 | current_transition_time = transition_time 734 | else: 735 | current_transition_time = 0 736 | 737 | # Apply dark color threshold and compute mask 738 | masked_frame = FRAME_COLOR_LIB.apply_frame_mask(current_frame, channels_min_threshold * channels_min_threshold_flicker_correction) 739 | 740 | current_brightness = FRAME_COLOR_LIB.calculate_frame_brightness(masked_frame, 741 | dim_brightness, starting_brightness, min_non_zero_count * min_non_zero_count_flicker_correction, 742 | max_non_zero_count) 743 | 744 | # Turn on/off depending on result brightness 745 | if not dim_lights_instead_of_turn_off: 746 | if current_brightness <= dim_brightness: 747 | go_dark = True 748 | else: 749 | go_dark = False 750 | 751 | # Calculate relevant color for this frame 752 | result_color = FRAME_COLOR_LIB.calculate_hue_color( 753 | masked_frame, (number_of_k_means_clusters-k_means_flicker_correction), color_spread_threshold, 754 | channels_min_threshold * channels_min_threshold_flicker_correction, channels_max_threshold) 755 | 756 | # save brightness, go_dark and diff from prev frame for later use 757 | result_color.brightness = current_brightness 758 | result_color.go_dark = go_dark 759 | if(comparison_result is not None): 760 | result_color.diff_from_prev = comparison_result[0][0] 761 | 762 | # Compare Current Calculated Color with previous Color 763 | # Skip frame if result color is almost identical 764 | if prev_color is not None and\ 765 | abs(current_brightness - prev_brightness) < brightness_skip_sensitivity: 766 | skip_frame = True 767 | for j in range(0, 3): 768 | ch_diff = math.fabs( 769 | int(prev_color.color[j]) - int(result_color.color[j])) 770 | if ch_diff > color_skip_sensitivity: 771 | skip_frame = False 772 | break 773 | if skip_frame: 774 | continue 775 | 776 | # Anti Flicker algorithm 777 | # BETA VERSION 778 | if(flicker_prevent): 779 | result_buffer.append(result_color) 780 | current_buffer_size = len(result_buffer) 781 | 782 | # reset flicker temporary adjustments if any 783 | if(result_color.diff_from_prev is not None and \ 784 | result_color.diff_from_prev > flicker_correction_reset_sensitivity): 785 | if(k_means_flicker_correction != 0): 786 | color_flicker_countdown = 0 787 | k_means_flicker_correction = 0 788 | print('reset color FLICKER adjustments') 789 | if(min_non_zero_count_flicker_correction != 1): 790 | on_off_flicker_countdown = 0 791 | min_non_zero_count_flicker_correction = 1 792 | channels_min_threshold_flicker_correction = 1 793 | print('reset on/off FLICKER adjustments') 794 | else: 795 | color_flicker_count = 0 796 | current_buffer_size = len(result_buffer) 797 | if current_buffer_size >= color_flicker_prevent_required_input_frames_count: 798 | for j in range (current_buffer_size - (color_flicker_prevent_required_input_frames_count), \ 799 | current_buffer_size-1): 800 | color_check = FRAME_COLOR_LIB.frame_colors_are_similar(\ 801 | result_buffer[j], result_buffer[j-1], 802 | color_skip_sensitivity, brightness_skip_sensitivity) 803 | if result_buffer[j].diff_from_prev is not None and\ 804 | result_buffer[j].diff_from_prev < color_flicker_frame_match_threshold and\ 805 | not color_check: 806 | color_flicker_count += 1 807 | 808 | if color_flicker_count >= color_flicker_count_threshold: 809 | # Lower the k means if color flicker detected 810 | k_means_flicker_correction = int(number_of_k_means_clusters/2) - 1 811 | print('COLOR FLICKER AVOIDED | new K: {0}'.format(number_of_k_means_clusters - k_means_flicker_correction)) 812 | color_flicker_countdown = flicker_correction_frame_count_until_reset 813 | 814 | # ON OFF FLICKER 815 | if not dim_lights_instead_of_turn_off and go_dark: 816 | on_off_count = 0 817 | current_buffer_size = len(result_buffer) 818 | if current_buffer_size >= on_off_flicker_prevent_required_input_frames_count: 819 | for j in range (current_buffer_size - (on_off_flicker_prevent_required_input_frames_count + 1), \ 820 | current_buffer_size-1): 821 | if not result_buffer[j].go_dark and result_buffer[j+1].go_dark: 822 | on_off_count += 1 823 | 824 | if on_off_count >= on_off_flicker_count_threshold: 825 | # Lower the turn off threshold if lights are switched off many times in a short period of time 826 | min_non_zero_count_flicker_correction = min_non_zero_count_on_off_flicker_correction_value 827 | channels_min_threshold_flicker_correction = channels_min_threshold_on_off_flicker_correction_value 828 | print('ON/OFF FLICKER AVOIDED | new min nz: {0}'.format(min_non_zero_count * min_non_zero_count_flicker_correction)) 829 | on_off_flicker_countdown = flicker_correction_frame_count_until_reset 830 | 831 | # Keep buffer at specified size 832 | if len(result_buffer) == result_buffer_size: 833 | result_buffer.pop(0) 834 | 835 | if color_flicker_countdown >= 1: 836 | color_flicker_countdown -= 1 837 | if color_flicker_countdown == 0: 838 | k_means_flicker_correction = 0 839 | print('back from color FLICKER') 840 | 841 | if on_off_flicker_countdown >= 1: 842 | on_off_flicker_countdown -= 1 843 | if on_off_flicker_countdown == 0: 844 | min_non_zero_count_flicker_correction = 1 845 | channels_min_threshold_flicker_correction = 1 846 | print('back from on off FLICKER') 847 | 848 | 849 | # print('RESULT_BUFFER count: {0}'.format(len(RESULT_BUFFER))) 850 | 851 | # Send color to Hue if update flag is clear 852 | if CAN_UPDATE_HUE: 853 | 854 | # Use prev color if RGB are all 0 855 | # Avoids unpleasant blue shift 856 | if result_color.color[0] == 0 and result_color.color[1] == 0\ 857 | and result_color.color[2] == 0: 858 | if prev_color: 859 | result_color = prev_color 860 | else: 861 | result_color.color[0] = DEFAULT_DARK_COLOR[0] 862 | result_color.color[1] = DEFAULT_DARK_COLOR[1] 863 | result_color.color[2] = DEFAULT_DARK_COLOR[2] 864 | else: 865 | prev_color = result_color 866 | 867 | prev_frame = current_frame 868 | prev_brightness = current_brightness 869 | 870 | # Timer that limits the requests to hue bridge in order to prevent bottlenecks 871 | CAN_UPDATE_HUE = False 872 | update_timer = threading.Timer( 873 | request_timeout, clear_update_flag) 874 | update_timer.start() 875 | 876 | print ('Updating with RGB: [{0}, {1}, {2}]'.format( 877 | result_color.color[0], result_color.color[1], result_color.color[2])) 878 | print ('brightness: {0}'.format(current_brightness)) 879 | 880 | if dim_lights_instead_of_turn_off: 881 | command = {'xy': result_color.get_hue_color(), 882 | 'bri': current_brightness, 883 | 'transitiontime': current_transition_time} 884 | else: 885 | command = { 886 | 'on': not go_dark, 887 | 'xy': result_color.get_hue_color(), 888 | 'bri': current_brightness, 889 | 'transitiontime': current_transition_time} 890 | 891 | # Slower request possibilities at the time of writing: 892 | #bridge.set_light(my_light_names, command) 893 | #bridge.set_group(group_id, command) 894 | # r = requests.put('http://%s/api/%s/groups/%s/action'%(bridge_ip, user, group_id), data = json.dumps(command)) 895 | 896 | for hue_light_id in my_light_ids: 897 | requests.put('http://%s/api/%s/lights/%s/state'%(bridge_ip, user, hue_light_id), data = json.dumps(command)) 898 | #print(r.text) 899 | 900 | prev_fps = 1 / (time.time()-last_time) 901 | print ('fps: {0}'.format(prev_fps)) 902 | 903 | # BETA VERSION !!! 904 | # If bad FPS 905 | # Adjust params to achieve smooth performance 906 | if(auto_adjust_performance 907 | and prev_fps 908 | and prev_fps < hue_max_requests_per_second): 909 | # Skip random bad fps frames 910 | if(adjust_counter == adjust_counter_limit - 1): 911 | if adjust_position % 6 < 3 and input_image_reduced_size >= 30: 912 | print("adjust shrink image size") 913 | input_image_reduced_size -= 10 914 | elif adjust_position % 6 < 3: 915 | # increment if there is nothing to do here 916 | adjust_position = 3 917 | 918 | if adjust_position % 6 == 3 and number_of_k_means_clusters > 4: 919 | print("adjust k means") 920 | number_of_k_means_clusters -= 1 921 | elif adjust_position % 6 == 3: 922 | # increment if there is nothing to do here 923 | adjust_position += 1 924 | 925 | if adjust_position % 6 == 4 and channels_min_threshold < 90: 926 | print("adjust channels_min_threshold") 927 | channels_min_threshold += 5 928 | elif adjust_position % 6 == 4: 929 | # increment if there is nothing to do here 930 | adjust_position += 1 931 | 932 | if adjust_position % 6 == 5 and frame_match_sensitivity < 0.1: 933 | print("adjust match sensitivity") 934 | frame_match_sensitivity += 0.001 935 | elif adjust_position % 6 == 5: 936 | # increment if there is nothing to do here 937 | adjust_position += 1 938 | 939 | adjust_position += 1 940 | adjust_counter = 0 941 | print("adjusted params to meet performance") 942 | print ('input_image_reduced_size: {0}'.format(input_image_reduced_size)) 943 | print ('channels_min_threshold: {0}'.format(channels_min_threshold)) 944 | print ('number_of_k_means_clusters: {0}'.format(number_of_k_means_clusters)) 945 | print ('frame_match_sensitivity: {0}'.format(frame_match_sensitivity)) 946 | else: 947 | adjust_counter += 1 948 | else: 949 | adjust_counter = 0 950 | 951 | if __name__ == "__main__": 952 | main(sys.argv[1:]) 953 | -------------------------------------------------------------------------------- /phue_lib.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | ''' 5 | phue by Nathanaël Lécaudé - A Philips Hue Python library 6 | Contributions by Marshall Perrin, Justin Lintz 7 | https://github.com/studioimaginaire/phue 8 | Original protocol hacking by rsmck : http://rsmck.co.uk/hue 9 | 10 | Published under the MIT license - See LICENSE file for more details. 11 | 12 | "Hue Personal Wireless Lighting" is a trademark owned by Koninklijke Philips Electronics N.V., see www.meethue.com for more information. 13 | I am in no way affiliated with the Philips organization. 14 | 15 | ''' 16 | 17 | import json 18 | import logging 19 | import os 20 | import platform 21 | import sys 22 | import socket 23 | if sys.version_info[0] > 2: 24 | PY3K = True 25 | else: 26 | PY3K = False 27 | 28 | if PY3K: 29 | import http.client as httplib 30 | else: 31 | import httplib 32 | 33 | logger = logging.getLogger('phue') 34 | 35 | 36 | if platform.system() == 'Windows': 37 | USER_HOME = 'USERPROFILE' 38 | else: 39 | USER_HOME = 'HOME' 40 | 41 | __version__ = '1.1' 42 | 43 | 44 | def is_string(data): 45 | """Utility method to see if data is a string.""" 46 | if PY3K: 47 | return isinstance(data, str) 48 | else: 49 | return isinstance(data, str) or isinstance(data, unicode) # noqa 50 | 51 | 52 | class PhueException(Exception): 53 | 54 | def __init__(self, id, message): 55 | self.id = id 56 | self.message = message 57 | 58 | 59 | class PhueRegistrationException(PhueException): 60 | pass 61 | 62 | 63 | class PhueRequestTimeout(PhueException): 64 | pass 65 | 66 | 67 | class Light(object): 68 | 69 | """ Hue Light object 70 | 71 | Light settings can be accessed or set via the properties of this object. 72 | 73 | """ 74 | def __init__(self, bridge, light_id): 75 | self.bridge = bridge 76 | self.light_id = light_id 77 | 78 | self._name = None 79 | self._on = None 80 | self._brightness = None 81 | self._colormode = None 82 | self._hue = None 83 | self._saturation = None 84 | self._xy = None 85 | self._colortemp = None 86 | self._effect = None 87 | self._alert = None 88 | self.transitiontime = None # default 89 | self._reset_bri_after_on = None 90 | self._reachable = None 91 | self._type = None 92 | 93 | def __repr__(self): 94 | # like default python repr function, but add light name 95 | return '<{0}.{1} object "{2}" at {3}>'.format( 96 | self.__class__.__module__, 97 | self.__class__.__name__, 98 | self.name, 99 | hex(id(self))) 100 | 101 | # Wrapper functions for get/set through the bridge, adding support for 102 | # remembering the transitiontime parameter if the user has set it 103 | def _get(self, *args, **kwargs): 104 | return self.bridge.get_light(self.light_id, *args, **kwargs) 105 | 106 | def _set(self, *args, **kwargs): 107 | 108 | if self.transitiontime is not None: 109 | kwargs['transitiontime'] = self.transitiontime 110 | logger.debug("Setting with transitiontime = {0} ds = {1} s".format( 111 | self.transitiontime, float(self.transitiontime) / 10)) 112 | 113 | if (args[0] == 'on' and args[1] is False) or ( 114 | kwargs.get('on', True) is False): 115 | self._reset_bri_after_on = True 116 | return self.bridge.set_light(self.light_id, *args, **kwargs) 117 | 118 | @property 119 | def name(self): 120 | '''Get or set the name of the light [string]''' 121 | if PY3K: 122 | self._name = self._get('name') 123 | else: 124 | self._name = self._get('name').encode('utf-8') 125 | return self._name 126 | 127 | @name.setter 128 | def name(self, value): 129 | old_name = self.name 130 | self._name = value 131 | self._set('name', self._name) 132 | 133 | logger.debug("Renaming light from '{0}' to '{1}'".format( 134 | old_name, value)) 135 | 136 | self.bridge.lights_by_name[self.name] = self 137 | del self.bridge.lights_by_name[old_name] 138 | 139 | @property 140 | def on(self): 141 | '''Get or set the state of the light [True|False]''' 142 | self._on = self._get('on') 143 | return self._on 144 | 145 | @on.setter 146 | def on(self, value): 147 | 148 | # Some added code here to work around known bug where 149 | # turning off with transitiontime set makes it restart on brightness = 1 150 | # see 151 | # http://www.everyhue.com/vanilla/discussion/204/bug-with-brightness-when-requesting-ontrue-transitiontime5 152 | 153 | # if we're turning off, save whether this bug in the hardware has been 154 | # invoked 155 | if self._on and value is False: 156 | self._reset_bri_after_on = self.transitiontime is not None 157 | if self._reset_bri_after_on: 158 | logger.warning( 159 | 'Turned off light with transitiontime specified, brightness will be reset on power on') 160 | 161 | self._set('on', value) 162 | 163 | # work around bug by resetting brightness after a power on 164 | if self._on is False and value is True: 165 | if self._reset_bri_after_on: 166 | logger.warning( 167 | 'Light was turned off with transitiontime specified, brightness needs to be reset now.') 168 | self.brightness = self._brightness 169 | self._reset_bri_after_on = False 170 | 171 | self._on = value 172 | 173 | @property 174 | def colormode(self): 175 | '''Get the color mode of the light [hs|xy|ct]''' 176 | self._colormode = self._get('colormode') 177 | return self._colormode 178 | 179 | @property 180 | def brightness(self): 181 | '''Get or set the brightness of the light [0-254]. 182 | 183 | 0 is not off''' 184 | 185 | self._brightness = self._get('bri') 186 | return self._brightness 187 | 188 | @brightness.setter 189 | def brightness(self, value): 190 | self._brightness = value 191 | self._set('bri', self._brightness) 192 | 193 | @property 194 | def hue(self): 195 | '''Get or set the hue of the light [0-65535]''' 196 | self._hue = self._get('hue') 197 | return self._hue 198 | 199 | @hue.setter 200 | def hue(self, value): 201 | self._hue = int(value) 202 | self._set('hue', self._hue) 203 | 204 | @property 205 | def saturation(self): 206 | '''Get or set the saturation of the light [0-254] 207 | 208 | 0 = white 209 | 254 = most saturated 210 | ''' 211 | self._saturation = self._get('sat') 212 | return self._saturation 213 | 214 | @saturation.setter 215 | def saturation(self, value): 216 | self._saturation = value 217 | self._set('sat', self._saturation) 218 | 219 | @property 220 | def xy(self): 221 | '''Get or set the color coordinates of the light [ [0.0-1.0, 0.0-1.0] ] 222 | 223 | This is in a color space similar to CIE 1931 (but not quite identical) 224 | ''' 225 | self._xy = self._get('xy') 226 | return self._xy 227 | 228 | @xy.setter 229 | def xy(self, value): 230 | self._xy = value 231 | self._set('xy', self._xy) 232 | 233 | @property 234 | def colortemp(self): 235 | '''Get or set the color temperature of the light, in units of mireds [154-500]''' 236 | self._colortemp = self._get('ct') 237 | return self._colortemp 238 | 239 | @colortemp.setter 240 | def colortemp(self, value): 241 | if value < 154: 242 | logger.warn('154 mireds is coolest allowed color temp') 243 | elif value > 500: 244 | logger.warn('500 mireds is warmest allowed color temp') 245 | self._colortemp = value 246 | self._set('ct', self._colortemp) 247 | 248 | @property 249 | def colortemp_k(self): 250 | '''Get or set the color temperature of the light, in units of Kelvin [2000-6500]''' 251 | self._colortemp = self._get('ct') 252 | return int(round(1e6 / self._colortemp)) 253 | 254 | @colortemp_k.setter 255 | def colortemp_k(self, value): 256 | if value > 6500: 257 | logger.warn('6500 K is max allowed color temp') 258 | value = 6500 259 | elif value < 2000: 260 | logger.warn('2000 K is min allowed color temp') 261 | value = 2000 262 | 263 | colortemp_mireds = int(round(1e6 / value)) 264 | logger.debug("{0:d} K is {1} mireds".format(value, colortemp_mireds)) 265 | self.colortemp = colortemp_mireds 266 | 267 | @property 268 | def effect(self): 269 | '''Check the effect setting of the light. [none|colorloop]''' 270 | self._effect = self._get('effect') 271 | return self._effect 272 | 273 | @effect.setter 274 | def effect(self, value): 275 | self._effect = value 276 | self._set('effect', self._effect) 277 | 278 | @property 279 | def alert(self): 280 | '''Get or set the alert state of the light [select|lselect|none]''' 281 | self._alert = self._get('alert') 282 | return self._alert 283 | 284 | @alert.setter 285 | def alert(self, value): 286 | if value is None: 287 | value = 'none' 288 | self._alert = value 289 | self._set('alert', self._alert) 290 | 291 | @property 292 | def reachable(self): 293 | '''Get the reachable state of the light [boolean]''' 294 | self._reachable = self._get('reachable') 295 | return self._reachable 296 | 297 | @property 298 | def type(self): 299 | '''Get the type of the light [string]''' 300 | self._type = self._get('type') 301 | return self._type 302 | 303 | 304 | class SensorState(dict): 305 | def __init__(self, bridge, sensor_id): 306 | self._bridge = bridge 307 | self._sensor_id = sensor_id 308 | 309 | def __setitem__(self, key, value): 310 | dict.__setitem__(self, key, value) 311 | self._bridge.set_sensor_state(self._sensor_id, self) 312 | 313 | 314 | class SensorConfig(dict): 315 | def __init__(self, bridge, sensor_id): 316 | self._bridge = bridge 317 | self._sensor_id = sensor_id 318 | 319 | def __setitem__(self, key, value): 320 | dict.__setitem__(self, key, value) 321 | self._bridge.set_sensor_config(self._sensor_id, self) 322 | 323 | 324 | class Sensor(object): 325 | 326 | """ Hue Sensor object 327 | 328 | Sensor config and state can be read and updated via the properties of this object 329 | 330 | """ 331 | def __init__(self, bridge, sensor_id): 332 | self.bridge = bridge 333 | self.sensor_id = sensor_id 334 | 335 | self._name = None 336 | self._model = None 337 | self._swversion = None 338 | self._type = None 339 | self._uniqueid = None 340 | self._manufacturername = None 341 | self._state = SensorState(bridge, sensor_id) 342 | self._config = {} 343 | self._recycle = None 344 | 345 | def __repr__(self): 346 | # like default python repr function, but add sensor name 347 | return '<{0}.{1} object "{2}" at {3}>'.format( 348 | self.__class__.__module__, 349 | self.__class__.__name__, 350 | self.name, 351 | hex(id(self))) 352 | 353 | # Wrapper functions for get/set through the bridge 354 | def _get(self, *args, **kwargs): 355 | return self.bridge.get_sensor(self.sensor_id, *args, **kwargs) 356 | 357 | def _set(self, *args, **kwargs): 358 | return self.bridge.set_sensor(self.sensor_id, *args, **kwargs) 359 | 360 | @property 361 | def name(self): 362 | '''Get or set the name of the sensor [string]''' 363 | if PY3K: 364 | self._name = self._get('name') 365 | else: 366 | self._name = self._get('name').encode('utf-8') 367 | return self._name 368 | 369 | @name.setter 370 | def name(self, value): 371 | old_name = self.name 372 | self._name = value 373 | self._set('name', self._name) 374 | 375 | logger.debug("Renaming sensor from '{0}' to '{1}'".format( 376 | old_name, value)) 377 | 378 | self.bridge.sensors_by_name[self.name] = self 379 | del self.bridge.sensors_by_name[old_name] 380 | 381 | @property 382 | def modelid(self): 383 | '''Get a unique identifier of the hardware model of this sensor [string]''' 384 | self._modelid = self._get('modelid') 385 | return self._modelid 386 | 387 | @property 388 | def swversion(self): 389 | '''Get the software version identifier of the sensor's firmware [string]''' 390 | self._swversion = self._get('swversion') 391 | return self._swversion 392 | 393 | @property 394 | def type(self): 395 | '''Get the sensor type of this device [string]''' 396 | self._type = self._get('type') 397 | return self._type 398 | 399 | @property 400 | def uniqueid(self): 401 | '''Get the unique device ID of this sensor [string]''' 402 | self._uniqueid = self._get('uniqueid') 403 | return self._uniqueid 404 | 405 | @property 406 | def manufacturername(self): 407 | '''Get the name of the manufacturer [string]''' 408 | self._manufacturername = self._get('manufacturername') 409 | return self._manufacturername 410 | 411 | @property 412 | def state(self): 413 | ''' A dictionary of sensor state. Some values can be updated, some are read-only. [dict]''' 414 | data = self._get('state') 415 | self._state.clear() 416 | self._state.update(data) 417 | return self._state 418 | 419 | @state.setter 420 | def state(self, data): 421 | self._state.clear() 422 | self._state.update(data) 423 | 424 | @property 425 | def config(self): 426 | ''' A dictionary of sensor config. Some values can be updated, some are read-only. [dict]''' 427 | data = self._get('config') 428 | self._config.clear() 429 | self._config.update(data) 430 | return self._config 431 | 432 | @config.setter 433 | def config(self, data): 434 | self._config.clear() 435 | self._config.update(data) 436 | 437 | @property 438 | def recycle(self): 439 | ''' True if this resource should be automatically removed when the last reference to it disappears [bool]''' 440 | self._recycle = self._get('manufacturername') 441 | return self._manufacturername 442 | 443 | 444 | class Group(Light): 445 | 446 | """ A group of Hue lights, tracked as a group on the bridge 447 | 448 | Example: 449 | 450 | >>> b = Bridge() 451 | >>> g1 = Group(b, 1) 452 | >>> g1.hue = 50000 # all lights in that group turn blue 453 | >>> g1.on = False # all will turn off 454 | 455 | >>> g2 = Group(b, 'Kitchen') # you can also look up groups by name 456 | >>> # will raise a LookupError if the name doesn't match 457 | 458 | """ 459 | 460 | def __init__(self, bridge, group_id): 461 | Light.__init__(self, bridge, None) 462 | del self.light_id # not relevant for a group 463 | 464 | try: 465 | self.group_id = int(group_id) 466 | except: 467 | name = group_id 468 | groups = bridge.get_group() 469 | for idnumber, info in groups.items(): 470 | if PY3K: 471 | if info['name'] == name: 472 | self.group_id = int(idnumber) 473 | break 474 | else: 475 | if info['name'] == name.decode('utf-8'): 476 | self.group_id = int(idnumber) 477 | break 478 | else: 479 | raise LookupError("Could not find a group by that name.") 480 | 481 | # Wrapper functions for get/set through the bridge, adding support for 482 | # remembering the transitiontime parameter if the user has set it 483 | def _get(self, *args, **kwargs): 484 | return self.bridge.get_group(self.group_id, *args, **kwargs) 485 | 486 | def _set(self, *args, **kwargs): 487 | # let's get basic group functionality working first before adding 488 | # transition time... 489 | if self.transitiontime is not None: 490 | kwargs['transitiontime'] = self.transitiontime 491 | logger.debug("Setting with transitiontime = {0} ds = {1} s".format( 492 | self.transitiontime, float(self.transitiontime) / 10)) 493 | 494 | if (args[0] == 'on' and args[1] is False) or ( 495 | kwargs.get('on', True) is False): 496 | self._reset_bri_after_on = True 497 | return self.bridge.set_group(self.group_id, *args, **kwargs) 498 | 499 | @property 500 | def name(self): 501 | '''Get or set the name of the light group [string]''' 502 | if PY3K: 503 | self._name = self._get('name') 504 | else: 505 | self._name = self._get('name').encode('utf-8') 506 | return self._name 507 | 508 | @name.setter 509 | def name(self, value): 510 | old_name = self.name 511 | self._name = value 512 | logger.debug("Renaming light group from '{0}' to '{1}'".format( 513 | old_name, value)) 514 | self._set('name', self._name) 515 | 516 | @property 517 | def lights(self): 518 | """ Return a list of all lights in this group""" 519 | # response = self.bridge.request('GET', '/api/{0}/groups/{1}'.format(self.bridge.username, self.group_id)) 520 | # return [Light(self.bridge, int(l)) for l in response['lights']] 521 | return [Light(self.bridge, int(l)) for l in self._get('lights')] 522 | 523 | @lights.setter 524 | def lights(self, value): 525 | """ Change the lights that are in this group""" 526 | logger.debug("Setting lights in group {0} to {1}".format( 527 | self.group_id, str(value))) 528 | self._set('lights', value) 529 | 530 | 531 | class AllLights(Group): 532 | 533 | """ All the Hue lights connected to your bridge 534 | 535 | This makes use of the semi-documented feature that 536 | "Group 0" of lights appears to be a group automatically 537 | consisting of all lights. This is not returned by 538 | listing the groups, but is accessible if you explicitly 539 | ask for group 0. 540 | """ 541 | def __init__(self, bridge=None): 542 | if bridge is None: 543 | bridge = Bridge() 544 | Group.__init__(self, bridge, 0) 545 | 546 | 547 | class Scene(object): 548 | """ Container for Scene """ 549 | 550 | def __init__(self, sid, appdata=None, lastupdated=None, 551 | lights=None, locked=False, name="", owner="", 552 | picture="", recycle=False, version=0): 553 | self.scene_id = sid 554 | self.appdata = appdata or {} 555 | self.lastupdated = lastupdated 556 | if lights is not None: 557 | self.lights = sorted([int(x) for x in lights]) 558 | else: 559 | self.lights = [] 560 | self.locked = locked 561 | self.name = name 562 | self.owner = owner 563 | self.picture = picture 564 | self.recycle = recycle 565 | self.version = version 566 | 567 | def __repr__(self): 568 | # like default python repr function, but add sensor name 569 | return '<{0}.{1} id="{2}" name="{3}" lights={4}>'.format( 570 | self.__class__.__module__, 571 | self.__class__.__name__, 572 | self.scene_id, 573 | self.name, 574 | self.lights) 575 | 576 | 577 | class Bridge(object): 578 | 579 | """ Interface to the Hue ZigBee bridge 580 | 581 | You can obtain Light objects by calling the get_light_objects method: 582 | 583 | >>> b = Bridge(ip='192.168.1.100') 584 | >>> b.get_light_objects() 585 | [, 586 | ] 587 | 588 | Or more succinctly just by accessing this Bridge object as a list or dict: 589 | 590 | >>> b[1] 591 | 592 | >>> b['Kitchen'] 593 | 594 | 595 | 596 | 597 | """ 598 | def __init__(self, ip=None, username=None, config_file_path=None): 599 | """ Initialization function. 600 | 601 | Parameters: 602 | ------------ 603 | ip : string 604 | IP address as dotted quad 605 | username : string, optional 606 | 607 | """ 608 | 609 | if config_file_path is not None: 610 | self.config_file_path = config_file_path 611 | elif os.getenv(USER_HOME) is not None and os.access(os.getenv(USER_HOME), os.W_OK): 612 | self.config_file_path = os.path.join(os.getenv(USER_HOME), '.python_hue') 613 | elif 'iPad' in platform.machine() or 'iPhone' in platform.machine() or 'iPad' in platform.machine(): 614 | self.config_file_path = os.path.join(os.getenv(USER_HOME), 'Documents', '.python_hue') 615 | else: 616 | self.config_file_path = os.path.join(os.getcwd(), '.python_hue') 617 | 618 | self.ip = ip 619 | self.username = username 620 | self.lights_by_id = {} 621 | self.lights_by_name = {} 622 | self.sensors_by_id = {} 623 | self.sensors_by_name = {} 624 | self._name = None 625 | 626 | # self.minutes = 600 # these do not seem to be used anywhere? 627 | # self.seconds = 10 628 | 629 | self.connect() 630 | 631 | @property 632 | def name(self): 633 | '''Get or set the name of the bridge [string]''' 634 | self._name = self.request( 635 | 'GET', '/api/' + self.username + '/config')['name'] 636 | return self._name 637 | 638 | @name.setter 639 | def name(self, value): 640 | self._name = value 641 | data = {'name': self._name} 642 | self.request( 643 | 'PUT', '/api/' + self.username + '/config', data) 644 | 645 | def request(self, mode='GET', address=None, data=None): 646 | """ Utility function for HTTP GET/PUT requests for the API""" 647 | connection = httplib.HTTPConnection(self.ip, timeout=10) 648 | 649 | try: 650 | if mode == 'GET' or mode == 'DELETE': 651 | connection.request(mode, address) 652 | if mode == 'PUT' or mode == 'POST': 653 | connection.request(mode, address, json.dumps(data)) 654 | 655 | logger.debug("{0} {1} {2}".format(mode, address, str(data))) 656 | 657 | except socket.timeout: 658 | error = "{} Request to {}{} timed out.".format(mode, self.ip, address) 659 | 660 | logger.exception(error) 661 | raise PhueRequestTimeout(None, error) 662 | 663 | result = connection.getresponse() 664 | response = result.read() 665 | connection.close() 666 | if PY3K: 667 | return json.loads(response.decode('utf-8')) 668 | else: 669 | logger.debug(response) 670 | return json.loads(response) 671 | 672 | def get_ip_address(self, set_result=False): 673 | 674 | """ Get the bridge ip address from the meethue.com nupnp api """ 675 | 676 | connection = httplib.HTTPSConnection('www.meethue.com') 677 | connection.request('GET', '/api/nupnp') 678 | 679 | logger.info('Connecting to meethue.com/api/nupnp') 680 | 681 | result = connection.getresponse() 682 | 683 | if PY3K: 684 | data = json.loads(str(result.read(), encoding='utf-8')) 685 | else: 686 | result_str = result.read() 687 | data = json.loads(result_str) 688 | 689 | """ close connection after read() is done, to prevent issues with read() """ 690 | 691 | connection.close() 692 | 693 | ip = str(data[0]['internalipaddress']) 694 | 695 | if ip is not '': 696 | if set_result: 697 | self.ip = ip 698 | 699 | return ip 700 | else: 701 | return False 702 | 703 | def register_app(self): 704 | """ Register this computer with the Hue bridge hardware and save the resulting access token """ 705 | registration_request = {"devicetype": "python_hue"} 706 | response = self.request('POST', '/api', registration_request) 707 | for line in response: 708 | for key in line: 709 | if 'success' in key: 710 | with open(self.config_file_path, 'w') as f: 711 | logger.info( 712 | 'Writing configuration file to ' + self.config_file_path) 713 | f.write(json.dumps({self.ip: line['success']})) 714 | logger.info('Reconnecting to the bridge') 715 | self.connect() 716 | if 'error' in key: 717 | error_type = line['error']['type'] 718 | if error_type == 101: 719 | raise PhueRegistrationException(error_type, 720 | 'The link button has not been pressed in the last 30 seconds.') 721 | if error_type == 7: 722 | raise PhueException(error_type, 723 | 'Unknown username') 724 | 725 | def connect(self): 726 | """ Connect to the Hue bridge """ 727 | logger.info('Attempting to connect to the bridge...') 728 | # If the ip and username were provided at class init 729 | if self.ip is not None and self.username is not None: 730 | logger.info('Using ip: ' + self.ip) 731 | logger.info('Using username: ' + self.username) 732 | return 733 | 734 | if self.ip is None or self.username is None: 735 | try: 736 | with open(self.config_file_path) as f: 737 | config = json.loads(f.read()) 738 | if self.ip is None: 739 | self.ip = list(config.keys())[0] 740 | logger.info('Using ip from config: ' + self.ip) 741 | else: 742 | logger.info('Using ip: ' + self.ip) 743 | if self.username is None: 744 | self.username = config[self.ip]['username'] 745 | logger.info( 746 | 'Using username from config: ' + self.username) 747 | else: 748 | logger.info('Using username: ' + self.username) 749 | except Exception as e: 750 | logger.info( 751 | 'Error opening config file, will attempt bridge registration') 752 | self.register_app() 753 | 754 | def get_light_id_by_name(self, name): 755 | """ Lookup a light id based on string name. Case-sensitive. """ 756 | lights = self.get_light() 757 | for light_id in lights: 758 | if PY3K: 759 | if name == lights[light_id]['name']: 760 | return light_id 761 | else: 762 | if name.decode('utf-8') == lights[light_id]['name']: 763 | return light_id 764 | return False 765 | 766 | def get_light_objects(self, mode='list'): 767 | """Returns a collection containing the lights, either by name or id (use 'id' or 'name' as the mode) 768 | The returned collection can be either a list (default), or a dict. 769 | Set mode='id' for a dict by light ID, or mode='name' for a dict by light name. """ 770 | if self.lights_by_id == {}: 771 | lights = self.request('GET', '/api/' + self.username + '/lights/') 772 | for light in lights: 773 | self.lights_by_id[int(light)] = Light(self, int(light)) 774 | self.lights_by_name[lights[light][ 775 | 'name']] = self.lights_by_id[int(light)] 776 | if mode == 'id': 777 | return self.lights_by_id 778 | if mode == 'name': 779 | return self.lights_by_name 780 | if mode == 'list': 781 | # return ligts in sorted id order, dicts have no natural order 782 | return [self.lights_by_id[id] for id in sorted(self.lights_by_id)] 783 | 784 | def get_sensor_id_by_name(self, name): 785 | """ Lookup a sensor id based on string name. Case-sensitive. """ 786 | sensors = self.get_sensor() 787 | for sensor_id in sensors: 788 | if PY3K: 789 | if name == sensors[sensor_id]['name']: 790 | return sensor_id 791 | else: 792 | if name.decode('utf-8') == sensors[sensor_id]['name']: 793 | return sensor_id 794 | return False 795 | 796 | def get_sensor_objects(self, mode='list'): 797 | """Returns a collection containing the sensors, either by name or id (use 'id' or 'name' as the mode) 798 | The returned collection can be either a list (default), or a dict. 799 | Set mode='id' for a dict by sensor ID, or mode='name' for a dict by sensor name. """ 800 | if self.sensors_by_id == {}: 801 | sensors = self.request('GET', '/api/' + self.username + '/sensors/') 802 | for sensor in sensors: 803 | self.sensors_by_id[int(sensor)] = Sensor(self, int(sensor)) 804 | self.sensors_by_name[sensors[sensor][ 805 | 'name']] = self.sensors_by_id[int(sensor)] 806 | if mode == 'id': 807 | return self.sensors_by_id 808 | if mode == 'name': 809 | return self.sensors_by_name 810 | if mode == 'list': 811 | return self.sensors_by_id.values() 812 | 813 | def __getitem__(self, key): 814 | """ Lights are accessibly by indexing the bridge either with 815 | an integer index or string name. """ 816 | if self.lights_by_id == {}: 817 | self.get_light_objects() 818 | 819 | try: 820 | return self.lights_by_id[key] 821 | except: 822 | try: 823 | if PY3K: 824 | return self.lights_by_name[key] 825 | else: 826 | return self.lights_by_name[key.decode('utf-8')] 827 | except: 828 | raise KeyError( 829 | 'Not a valid key (integer index starting with 1, or light name): ' + str(key)) 830 | 831 | @property 832 | def lights(self): 833 | """ Access lights as a list """ 834 | return self.get_light_objects() 835 | 836 | def get_api(self): 837 | """ Returns the full api dictionary """ 838 | return self.request('GET', '/api/' + self.username) 839 | 840 | def get_light(self, light_id=None, parameter=None): 841 | """ Gets state by light_id and parameter""" 842 | 843 | if is_string(light_id): 844 | light_id = self.get_light_id_by_name(light_id) 845 | if light_id is None: 846 | return self.request('GET', '/api/' + self.username + '/lights/') 847 | state = self.request( 848 | 'GET', '/api/' + self.username + '/lights/' + str(light_id)) 849 | if parameter is None: 850 | return state 851 | if parameter in ['name', 'type', 'uniqueid', 'swversion']: 852 | return state[parameter] 853 | else: 854 | try: 855 | return state['state'][parameter] 856 | except KeyError as e: 857 | raise KeyError( 858 | 'Not a valid key, parameter %s is not associated with light %s)' 859 | % (parameter, light_id)) 860 | 861 | def set_light(self, light_id, parameter, value=None, transitiontime=None): 862 | """ Adjust properties of one or more lights. 863 | 864 | light_id can be a single lamp or an array of lamps 865 | parameters: 'on' : True|False , 'bri' : 0-254, 'sat' : 0-254, 'ct': 154-500 866 | 867 | transitiontime : in **deciseconds**, time for this transition to take place 868 | Note that transitiontime only applies to *this* light 869 | command, it is not saved as a setting for use in the future! 870 | Use the Light class' transitiontime attribute if you want 871 | persistent time settings. 872 | 873 | """ 874 | if isinstance(parameter, dict): 875 | data = parameter 876 | else: 877 | data = {parameter: value} 878 | 879 | if transitiontime is not None: 880 | data['transitiontime'] = int(round( 881 | transitiontime)) # must be int for request format 882 | 883 | light_id_array = light_id 884 | if isinstance(light_id, int) or is_string(light_id): 885 | light_id_array = [light_id] 886 | result = [] 887 | for light in light_id_array: 888 | logger.debug(str(data)) 889 | if parameter == 'name': 890 | result.append(self.request('PUT', '/api/' + self.username + '/lights/' + str( 891 | light_id), data)) 892 | else: 893 | if is_string(light): 894 | converted_light = self.get_light_id_by_name(light) 895 | else: 896 | converted_light = light 897 | result.append(self.request('PUT', '/api/' + self.username + '/lights/' + str( 898 | converted_light) + '/state', data)) 899 | if 'error' in list(result[-1][0].keys()): 900 | logger.warn("ERROR: {0} for light {1}".format( 901 | result[-1][0]['error']['description'], light)) 902 | 903 | logger.debug(result) 904 | return result 905 | 906 | # Sensors ##### 907 | 908 | @property 909 | def sensors(self): 910 | """ Access sensors as a list """ 911 | return self.get_sensor_objects() 912 | 913 | def create_sensor(self, name, modelid, swversion, sensor_type, uniqueid, manufacturername, state={}, config={}, recycle=False): 914 | """ Create a new sensor in the bridge. Returns (ID,None) of the new sensor or (None,message) if creation failed. """ 915 | data = { 916 | "name": name, 917 | "modelid": modelid, 918 | "swversion": swversion, 919 | "type": sensor_type, 920 | "uniqueid": uniqueid, 921 | "manufacturername": manufacturername, 922 | "recycle": recycle 923 | } 924 | if (isinstance(state, dict) and state != {}): 925 | data["state"] = state 926 | 927 | if (isinstance(config, dict) and config != {}): 928 | data["config"] = config 929 | 930 | result = self.request('POST', '/api/' + self.username + '/sensors/', data) 931 | 932 | if ("success" in result[0].keys()): 933 | new_id = result[0]["success"]["id"] 934 | logger.debug("Created sensor with ID " + new_id) 935 | new_sensor = Sensor(self, int(new_id)) 936 | self.sensors_by_id[new_id] = new_sensor 937 | self.sensors_by_name[name] = new_sensor 938 | return new_id, None 939 | else: 940 | logger.debug("Failed to create sensor:" + repr(result[0])) 941 | return None, result[0] 942 | 943 | def get_sensor(self, sensor_id=None, parameter=None): 944 | """ Gets state by sensor_id and parameter""" 945 | 946 | if is_string(sensor_id): 947 | sensor_id = self.get_sensor_id_by_name(sensor_id) 948 | if sensor_id is None: 949 | return self.request('GET', '/api/' + self.username + '/sensors/') 950 | data = self.request( 951 | 'GET', '/api/' + self.username + '/sensors/' + str(sensor_id)) 952 | 953 | if isinstance(data, list): 954 | logger.debug("Unable to read sensor with ID {0}: {1}".format(sensor_id, repr(data))) 955 | return None 956 | 957 | if parameter is None: 958 | return data 959 | return data[parameter] 960 | 961 | def set_sensor(self, sensor_id, parameter, value=None): 962 | """ Adjust properties of a sensor 963 | 964 | sensor_id must be a single sensor. 965 | parameters: 'name' : string 966 | 967 | """ 968 | if isinstance(parameter, dict): 969 | data = parameter 970 | else: 971 | data = {parameter: value} 972 | 973 | result = None 974 | logger.debug(str(data)) 975 | result = self.request('PUT', '/api/' + self.username + '/sensors/' + str( 976 | sensor_id), data) 977 | if 'error' in list(result[0].keys()): 978 | logger.warn("ERROR: {0} for sensor {1}".format( 979 | result[0]['error']['description'], sensor_id)) 980 | 981 | logger.debug(result) 982 | return result 983 | 984 | def set_sensor_state(self, sensor_id, parameter, value=None): 985 | """ Adjust the "state" object of a sensor 986 | 987 | sensor_id must be a single sensor. 988 | parameters: any parameter(s) present in the sensor's "state" dictionary. 989 | 990 | """ 991 | self.set_sensor_content(sensor_id, parameter, value, "state") 992 | 993 | def set_sensor_config(self, sensor_id, parameter, value=None): 994 | """ Adjust the "config" object of a sensor 995 | 996 | sensor_id must be a single sensor. 997 | parameters: any parameter(s) present in the sensor's "config" dictionary. 998 | 999 | """ 1000 | self.set_sensor_content(sensor_id, parameter, value, "config") 1001 | 1002 | def set_sensor_content(self, sensor_id, parameter, value=None, structure="state"): 1003 | """ Adjust the "state" or "config" structures of a sensor 1004 | """ 1005 | if (structure != "state" and structure != "config"): 1006 | logger.debug("set_sensor_current expects structure 'state' or 'config'.") 1007 | return False 1008 | 1009 | if isinstance(parameter, dict): 1010 | data = parameter.copy() 1011 | else: 1012 | data = {parameter: value} 1013 | 1014 | # Attempting to set this causes an error. 1015 | if "lastupdated" in data: 1016 | del data["lastupdated"] 1017 | 1018 | result = None 1019 | logger.debug(str(data)) 1020 | result = self.request('PUT', '/api/' + self.username + '/sensors/' + str( 1021 | sensor_id) + "/" + structure, data) 1022 | if 'error' in list(result[0].keys()): 1023 | logger.warn("ERROR: {0} for sensor {1}".format( 1024 | result[0]['error']['description'], sensor_id)) 1025 | 1026 | logger.debug(result) 1027 | return result 1028 | 1029 | def delete_scene(self, scene_id): 1030 | try: 1031 | return self.request('DELETE', '/api/' + self.username + '/scenes/' + str(scene_id)) 1032 | except: 1033 | logger.debug("Unable to delete scene with ID {0}".format(scene_id)) 1034 | 1035 | def delete_sensor(self, sensor_id): 1036 | try: 1037 | name = self.sensors_by_id[sensor_id].name 1038 | del self.sensors_by_name[name] 1039 | del self.sensors_by_id[sensor_id] 1040 | return self.request('DELETE', '/api/' + self.username + '/sensors/' + str(sensor_id)) 1041 | except: 1042 | logger.debug("Unable to delete nonexistent sensor with ID {0}".format(sensor_id)) 1043 | 1044 | # Groups of lights ##### 1045 | @property 1046 | def groups(self): 1047 | """ Access groups as a list """ 1048 | return [Group(self, int(groupid)) for groupid in self.get_group().keys()] 1049 | 1050 | def get_group_id_by_name(self, name): 1051 | """ Lookup a group id based on string name. Case-sensitive. """ 1052 | groups = self.get_group() 1053 | for group_id in groups: 1054 | if PY3K: 1055 | if name == groups[group_id]['name']: 1056 | return group_id 1057 | else: 1058 | if name.decode('utf-8') == groups[group_id]['name']: 1059 | return group_id 1060 | return False 1061 | 1062 | def get_group(self, group_id=None, parameter=None): 1063 | if is_string(group_id): 1064 | group_id = self.get_group_id_by_name(group_id) 1065 | if group_id is False: 1066 | logger.error('Group name does not exit') 1067 | return 1068 | if group_id is None: 1069 | return self.request('GET', '/api/' + self.username + '/groups/') 1070 | if parameter is None: 1071 | return self.request('GET', '/api/' + self.username + '/groups/' + str(group_id)) 1072 | elif parameter == 'name' or parameter == 'lights': 1073 | return self.request('GET', '/api/' + self.username + '/groups/' + str(group_id))[parameter] 1074 | else: 1075 | return self.request('GET', '/api/' + self.username + '/groups/' + str(group_id))['action'][parameter] 1076 | 1077 | def set_group(self, group_id, parameter, value=None, transitiontime=None): 1078 | """ Change light settings for a group 1079 | 1080 | group_id : int, id number for group 1081 | parameter : 'name' or 'lights' 1082 | value: string, or list of light IDs if you're setting the lights 1083 | 1084 | """ 1085 | 1086 | if isinstance(parameter, dict): 1087 | data = parameter 1088 | elif parameter == 'lights' and (isinstance(value, list) or isinstance(value, int)): 1089 | if isinstance(value, int): 1090 | value = [value] 1091 | data = {parameter: [str(x) for x in value]} 1092 | else: 1093 | data = {parameter: value} 1094 | 1095 | if transitiontime is not None: 1096 | data['transitiontime'] = int(round( 1097 | transitiontime)) # must be int for request format 1098 | 1099 | group_id_array = group_id 1100 | if isinstance(group_id, int) or is_string(group_id): 1101 | group_id_array = [group_id] 1102 | result = [] 1103 | for group in group_id_array: 1104 | logger.debug(str(data)) 1105 | if is_string(group): 1106 | converted_group = self.get_group_id_by_name(group) 1107 | else: 1108 | converted_group = group 1109 | if converted_group is False: 1110 | logger.error('Group name does not exit') 1111 | return 1112 | if parameter == 'name' or parameter == 'lights': 1113 | result.append(self.request('PUT', '/api/' + self.username + '/groups/' + str(converted_group), data)) 1114 | else: 1115 | result.append(self.request('PUT', '/api/' + self.username + '/groups/' + str(converted_group) + '/action', data)) 1116 | 1117 | if 'error' in list(result[-1][0].keys()): 1118 | logger.warn("ERROR: {0} for group {1}".format( 1119 | result[-1][0]['error']['description'], group)) 1120 | 1121 | logger.debug(result) 1122 | return result 1123 | 1124 | def create_group(self, name, lights=None): 1125 | """ Create a group of lights 1126 | 1127 | Parameters 1128 | ------------ 1129 | name : string 1130 | Name for this group of lights 1131 | lights : list 1132 | List of lights to be in the group. 1133 | 1134 | """ 1135 | data = {'lights': [str(x) for x in lights], 'name': name} 1136 | return self.request('POST', '/api/' + self.username + '/groups/', data) 1137 | 1138 | def delete_group(self, group_id): 1139 | return self.request('DELETE', '/api/' + self.username + '/groups/' + str(group_id)) 1140 | 1141 | # Scenes ##### 1142 | @property 1143 | def scenes(self): 1144 | return [Scene(k, **v) for k, v in self.get_scene().items()] 1145 | 1146 | def get_scene(self): 1147 | return self.request('GET', '/api/' + self.username + '/scenes') 1148 | 1149 | def activate_scene(self, group_id, scene_id, transition_time=4): 1150 | return self.request('PUT', '/api/' + self.username + '/groups/' + 1151 | str(group_id) + '/action', 1152 | { 1153 | "scene": scene_id, 1154 | "transitiontime": transition_time 1155 | }) 1156 | 1157 | def run_scene(self, group_name, scene_name, transition_time=4): 1158 | """Run a scene by group and scene name. 1159 | 1160 | As of 1.11 of the Hue API the scenes are accessable in the 1161 | API. With the gen 2 of the official HUE app everything is 1162 | organized by room groups. 1163 | 1164 | This provides a convenience way of activating scenes by group 1165 | name and scene name. If we find exactly 1 group and 1 scene 1166 | with the matching names, we run them. 1167 | 1168 | If we find more than one we run the first scene who has 1169 | exactly the same lights defined as the group. This is far from 1170 | perfect, but is convenient for setting lights symbolically (and 1171 | can be improved later). 1172 | 1173 | :param transition_time: The duration of the transition from the 1174 | light’s current state to the new state in a multiple of 100ms 1175 | :returns True if a scene was run, False otherwise 1176 | 1177 | """ 1178 | groups = [x for x in self.groups if x.name == group_name] 1179 | scenes = [x for x in self.scenes if x.name == scene_name] 1180 | if len(groups) != 1: 1181 | logger.warn("run_scene: More than 1 group found by name {}".format(group_name)) 1182 | return False 1183 | group = groups[0] 1184 | if len(scenes) == 0: 1185 | logger.warn("run_scene: No scene found {}".format(scene_name)) 1186 | return False 1187 | if len(scenes) == 1: 1188 | self.activate_scene(group.group_id, scenes[0].scene_id) 1189 | return True 1190 | # otherwise, lets figure out if one of the named scenes uses 1191 | # all the lights of the group 1192 | group_lights = sorted([x.light_id for x in group.lights]) 1193 | for scene in scenes: 1194 | if group_lights == scene.lights: 1195 | self.activate_scene(group.group_id, scene.scene_id) 1196 | return True 1197 | logger.warn("run_scene: did not find a scene: {} " 1198 | "that shared lights with group {}".format(scene_name, group_name)) 1199 | return False 1200 | 1201 | # Schedules ##### 1202 | def get_schedule(self, schedule_id=None, parameter=None): 1203 | if schedule_id is None: 1204 | return self.request('GET', '/api/' + self.username + '/schedules') 1205 | if parameter is None: 1206 | return self.request('GET', '/api/' + self.username + '/schedules/' + str(schedule_id)) 1207 | 1208 | def create_schedule(self, name, time, light_id, data, description=' '): 1209 | schedule = { 1210 | 'name': name, 1211 | 'localtime': time, 1212 | 'description': description, 1213 | 'command': 1214 | { 1215 | 'method': 'PUT', 1216 | 'address': ('/api/' + self.username + 1217 | '/lights/' + str(light_id) + '/state'), 1218 | 'body': data 1219 | } 1220 | } 1221 | return self.request('POST', '/api/' + self.username + '/schedules', schedule) 1222 | 1223 | def set_schedule_attributes(self, schedule_id, attributes): 1224 | """ 1225 | :param schedule_id: The ID of the schedule 1226 | :param attributes: Dictionary with attributes and their new values 1227 | """ 1228 | return self.request('PUT', '/api/' + self.username + '/schedules/' + str(schedule_id), data=attributes) 1229 | 1230 | def create_group_schedule(self, name, time, group_id, data, description=' '): 1231 | schedule = { 1232 | 'name': name, 1233 | 'localtime': time, 1234 | 'description': description, 1235 | 'command': 1236 | { 1237 | 'method': 'PUT', 1238 | 'address': ('/api/' + self.username + 1239 | '/groups/' + str(group_id) + '/action'), 1240 | 'body': data 1241 | } 1242 | } 1243 | return self.request('POST', '/api/' + self.username + '/schedules', schedule) 1244 | 1245 | def delete_schedule(self, schedule_id): 1246 | return self.request('DELETE', '/api/' + self.username + '/schedules/' + str(schedule_id)) 1247 | 1248 | if __name__ == '__main__': 1249 | import argparse 1250 | 1251 | logging.basicConfig(level=logging.DEBUG) 1252 | 1253 | parser = argparse.ArgumentParser() 1254 | parser.add_argument('--host', required=True) 1255 | parser.add_argument('--config-file-path', required=False) 1256 | args = parser.parse_args() 1257 | 1258 | while True: 1259 | try: 1260 | b = Bridge(args.host, config_file_path=args.config_file_path) 1261 | break 1262 | except PhueRegistrationException as e: 1263 | if PY3K: 1264 | input('Press button on Bridge then hit Enter to try again') 1265 | else: 1266 | raw_input('Press button on Bridge then hit Enter to try again') # noqa 1267 | --------------------------------------------------------------------------------