├── .gitignore ├── LICENSE ├── README.md ├── apps ├── birdfeeder │ ├── bird_detector.json │ ├── bird_detector.tflite │ ├── birdfeeder.jpg │ ├── birdfeeder_consts.py │ ├── european_bird_classifier.json │ ├── european_bird_classifier.tflite │ ├── handlers.py │ ├── info.json │ ├── main.py │ ├── north_american_bird_classifier.json │ └── north_american_bird_classifier.tflite ├── motionscope │ ├── analyze.py │ ├── camera.py │ ├── capture.py │ ├── centroidtracker.py │ ├── dataupdate.py │ ├── graphs.py │ ├── info.json │ ├── main.py │ ├── motion.py │ ├── motionscope.jpg │ ├── motionscope_consts.py │ ├── process.py │ ├── simplemotion.py │ └── tab.py ├── object_detector │ ├── handlers.py │ ├── info.json │ ├── main.py │ ├── object_detector.jpg │ ├── object_detector_consts.py │ └── train_detector.ipynb └── radar │ ├── font.ttf │ ├── handlers.py │ ├── info.json │ ├── main.py │ ├── radar.jpg │ └── radar_consts.py ├── examples ├── edge_detection │ ├── info.json │ └── main.py ├── pet_companion │ ├── hey.wav │ ├── info.json │ └── main.py ├── pictaker │ ├── info.json │ └── main.py ├── tflite │ ├── info.json │ └── main.py └── video │ ├── info.json │ └── main.py ├── install.sh ├── scripts ├── install_services ├── install_update ├── update_power_firmware ├── vizy_fu ├── vizy_power_monitor ├── vizy_power_off └── vizy_server ├── setup.py ├── src └── vizy │ ├── __init__.py │ ├── about.py │ ├── aboutdialog.py │ ├── appsdialog.py │ ├── exportprojectdialog.py │ ├── gclouddialog.py │ ├── ifttt.py │ ├── importprojectdialog.py │ ├── login │ ├── login.html │ └── vizy.png │ ├── media │ ├── bg.jpg │ ├── default_bg.jpg │ ├── favicon.ico │ ├── vizy.png │ └── vizy_eye.png │ ├── mediadisplayqueue.py │ ├── newprojectdialog.py │ ├── openprojectdialog.py │ ├── perspective.py │ ├── rebootdialog.py │ ├── remotedialog.py │ ├── systemdialog.py │ ├── test.jpg │ ├── textingdialog.py │ ├── timedialog.py │ ├── updatedialog.py │ ├── userdialog.py │ ├── users.py │ ├── vizy.py │ ├── vizypowerboard.py │ ├── vizyvisor.py │ ├── wificonnection.py │ └── wifidialog.py └── sys ├── vizy-power-monitor.service ├── vizy-power-off.service ├── vizy-server.service └── vizy_io-0.1.3.fwe /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | apps/motionscope/media/test.data 3 | apps/motionscope/media/test.raw 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vizy 2 | ## now with Chat Services -------------------------------------------------------------------------------- /apps/birdfeeder/bird_detector.json: -------------------------------------------------------------------------------- 1 | {"classes": ["Bird", "Non-bird"], "model": "efficientdet_lite0"} 2 | 3 | -------------------------------------------------------------------------------- /apps/birdfeeder/bird_detector.tflite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmedlabs/vizy/59e443cf61150b3b4072c1817665757b1aad611b/apps/birdfeeder/bird_detector.tflite -------------------------------------------------------------------------------- /apps/birdfeeder/birdfeeder.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmedlabs/vizy/59e443cf61150b3b4072c1817665757b1aad611b/apps/birdfeeder/birdfeeder.jpg -------------------------------------------------------------------------------- /apps/birdfeeder/birdfeeder_consts.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Vizy 3 | # 4 | # All Vizy source code is provided under the terms of the 5 | # GNU General Public License v2 (http://www.gnu.org/licenses/gpl-2.0.html). 6 | # Those wishing to use Vizy source code, software and/or 7 | # technologies under different licensing terms should contact us at 8 | # support@charmedlabs.com. 9 | # 10 | 11 | # Number of images to keep in the media directory 12 | IMAGES_KEEP = 100 13 | # Number of images to display in the media queue 14 | IMAGES_DISPLAY = 25 15 | # How long to wait (seconds) before picking best detection image for media queue 16 | PICKER_TIMEOUT = 60 17 | # Width of media queue images 18 | MEDIA_QUEUE_IMAGE_WIDTH = 300 19 | # Folder within Google Photos to save media 20 | GPHOTO_ALBUM = "Vizy Birdfeeder" 21 | 22 | # The I/O bit that's used to trigger the defense (e.g. sprinkler valve). It can 23 | # range from 0 to 3. See https://docs.vizycam.com/doku.php?id=wiki:pinouts 24 | DEFEND_BIT = 0 25 | 26 | # Use this for North American bird species 27 | CLASSIFIER = "north_american_bird_classifier.tflite" 28 | # Use this for European bird species 29 | #CLASSIFIER = "european_bird_classifier.tflite" 30 | 31 | # Maximum distance between detections before assuming they are different 32 | # detections (birds) 33 | TRACKER_DISAPPEARED_DISTANCE = 300 34 | # Numbers of frames that a detection has disappeared before assuming it's gone 35 | TRACKER_MAX_DISAPPEARED = 1 36 | # Set this to True if you want to do an analysis of detection 37 | # classes for a given tracked object. 38 | TRACKER_CLASS_SWITCH = True 39 | -------------------------------------------------------------------------------- /apps/birdfeeder/european_bird_classifier.json: -------------------------------------------------------------------------------- 1 | {"classes": ["Abyssian Roller", 2 | "Black Woodpecker", 3 | "Blue Tit", 4 | "Carrion Crow", 5 | "Chaffinch", 6 | "Coal Tit", 7 | "Common Blackbird", 8 | "Common Cuckoo", 9 | "Common Firecrest", 10 | "Common Raven", 11 | "Common Wood Pigeon", 12 | "Dunnock", 13 | "Eurasian Blackcap", 14 | "Eurasian Collard Dove", 15 | "Eurasian Hoopoe", 16 | "Eurasian Jay", 17 | "Eurasian Nuthatch", 18 | "Eurasian Turtle Dove", 19 | "Eurasian Wren", 20 | "European Goldfinch", 21 | "European Green Woodpecker", 22 | "European Greenfinch", 23 | "European Robin", 24 | "European Roller", 25 | "European Starling", 26 | "Goldcrest", 27 | "Golden Oriole", 28 | "Great Tit", 29 | "Grey-headed Woodpecker", 30 | "Hawfinch", 31 | "House Sparrow", 32 | "Long-tailed Tit", 33 | "Magpie", 34 | "Marsh Tit", 35 | "Song Thrush", 36 | "Spotted Woodpecker", 37 | "Stock Dove", 38 | "Tree Sparrow", 39 | "White Wagtail", 40 | "Willow Tit" 41 | ], 42 | "model": "efficientnet_lite0" 43 | } 44 | -------------------------------------------------------------------------------- /apps/birdfeeder/european_bird_classifier.tflite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmedlabs/vizy/59e443cf61150b3b4072c1817665757b1aad611b/apps/birdfeeder/european_bird_classifier.tflite -------------------------------------------------------------------------------- /apps/birdfeeder/handlers.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Vizy 3 | # 4 | # All Vizy source code is provided under the terms of the 5 | # GNU General Public License v2 (http://www.gnu.org/licenses/gpl-2.0.html). 6 | # Those wishing to use Vizy source code, software and/or 7 | # technologies under different licensing terms should contact us at 8 | # support@charmedlabs.com. 9 | # 10 | 11 | from kritter.ktextvisor import KtextVisor, KtextVisorTable, Image, Video 12 | 13 | # This gets called when a noteworthy event happens. 14 | # You can insert your own code here :) 15 | def handle_event(self, event): 16 | print(f"handle_event: {event}") 17 | # Deal with "trigger" events 18 | if event['event_type']=='trigger': 19 | if self.tv: 20 | # Send text message with timestamp, detected object class, and curated image 21 | self.tv.send([f"{event['timestamp']} {event['class']}", Image(event['image'])]) 22 | 23 | # This gets called when Vizy gets a text message (Telegram). 24 | # You can insert your own code here :) 25 | def handle_text(self, words, sender, context): 26 | print(f"handle_text from {sender}: {words}, context: {context}") 27 | -------------------------------------------------------------------------------- /apps/birdfeeder/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Birdfeeder", 3 | "version": "2.1", 4 | "author": "Charmed Labs", 5 | "email": "support@charmedlabs.com", 6 | "files": ["main.py", "handlers.py", "$VIZY_HOME/etc/birdfeeder_consts.py"], 7 | "description": "Identifies birds, takes pictures, and defends.", 8 | "url": "https://docs.vizycam.com/doku.php?id=wiki:birdfeeder_app", 9 | "image": "birdfeeder.jpg" 10 | } 11 | -------------------------------------------------------------------------------- /apps/birdfeeder/north_american_bird_classifier.json: -------------------------------------------------------------------------------- 1 | {"classes": ["American Bushtit", 2 | "American Crow", 3 | "American Goldfinch", 4 | "American Robin", 5 | "Baltimore Oriole", 6 | "Black-capped Chickadee", 7 | "Blue Jay", 8 | "Brown Creeper", 9 | "Brown-headed Cowbird", 10 | "California Scrub Jay", 11 | "California Towhee", 12 | "Carolina Chickadee", 13 | "Carolina Wren", 14 | "Cedar Waxwing", 15 | "Chestnut-backed Chickadee", 16 | "Common Grackle", 17 | "Common Pigeon", 18 | "Common Raven", 19 | "Common Starling", 20 | "Curve-billed Thrasher", 21 | "Dark-eyed Junco", 22 | "Downy Woodpecker", 23 | "Eastern Bluebird", 24 | "Eurasian Collared Dove", 25 | "House Finch", 26 | "Inca Dove", 27 | "Indigo Bunting", 28 | "Lesser Goldfinch", 29 | "Mourning Dove", 30 | "Northern Cardinal", 31 | "Northern Mockingbird", 32 | "Painted Bunting", 33 | "Pileated Woodpecker", 34 | "Pine Siskin", 35 | "Red-Bellied Woodpecker", 36 | "Red-winged Blackbird", 37 | "Rose-breasted Grosbeak", 38 | "Ruby-Crowned Kinglet", 39 | "Song Sparrow", 40 | "Spotted Towhee", 41 | "Steller's Jay", 42 | "Tufted Titmouse", 43 | "Western Meadowlark", 44 | "White-breasted Nuthatch", 45 | "White-crowned Sparrow", 46 | "White-throated Sparrow", 47 | "White-winged Dove" 48 | ], 49 | "model": "efficientnet_lite0" 50 | } 51 | -------------------------------------------------------------------------------- /apps/birdfeeder/north_american_bird_classifier.tflite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmedlabs/vizy/59e443cf61150b3b4072c1817665757b1aad611b/apps/birdfeeder/north_american_bird_classifier.tflite -------------------------------------------------------------------------------- /apps/motionscope/camera.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Vizy 3 | # 4 | # All Vizy source code is provided under the terms of the 5 | # GNU General Public License v2 (http://www.gnu.org/licenses/gpl-2.0.html). 6 | # Those wishing to use Vizy source code, software and/or 7 | # technologies under different licensing terms should contact us at 8 | # support@charmedlabs.com. 9 | # 10 | 11 | from tab import Tab 12 | import kritter 13 | from dash_devices.dependencies import Output 14 | import dash_bootstrap_components as dbc 15 | 16 | 17 | MIN_FRAMERATE = 1 18 | 19 | class Camera(Tab): 20 | 21 | def __init__(self, main): 22 | 23 | super().__init__("Camera", main.data) 24 | self.main = main 25 | self.stream = main.camera.stream() 26 | 27 | style = {"label_width": 3, "control_width": 6, "max_width": main.config_consts.WIDTH} 28 | 29 | modes = ["640x480x10bpp (cropped)", "768x432x10bpp"] 30 | all_modes = main.camera.getmodes() 31 | main.perspective.set_video_info_modes([all_modes[m] for m in modes]) 32 | 33 | self.mode = kritter.Kdropdown(name='Camera mode', options=modes, style=style) 34 | self.brightness = kritter.Kslider(name="Brightness", mxs=(0, 100, 1), format=lambda val: f'{val}%', style=style) 35 | self.framerate = kritter.Kslider(name="Framerate", mxs=(max(int(main.camera.min_framerate), MIN_FRAMERATE), int(main.camera.max_framerate), 1), format=lambda val : f'{val} fps', updatemode='mouseup', style=style) 36 | self.autoshutter = kritter.Kcheckbox(name='Auto-shutter', style=style) 37 | self.shutter = kritter.Kslider(name="Shutter-speed", mxs=(.0001, 1/main.camera.framerate, .0001), format=lambda val: f'{val:.4f}s', style=style) 38 | shutter_cont = dbc.Collapse(self.shutter, id=self.kapp.new_id(), is_open=not main.camera.autoshutter, style=style) 39 | self.awb = kritter.Kcheckbox(name='Auto-white-balance', style=style) 40 | self.red_gain = kritter.Kslider(name="Red gain", mxs=(0.05, 2.0, 0.01), style=style) 41 | self.blue_gain = kritter.Kslider(name="Blue gain", mxs=(0.05, 2.0, 0.01), style=style) 42 | awb_gains = dbc.Collapse([self.red_gain, self.blue_gain], id=self.kapp.new_id()) 43 | 44 | self.settings_map = {"mode": self.mode, "brightness": self.brightness, "framerate": self.framerate, "autoshutter": self.autoshutter, "shutter": self.shutter, "awb": self.awb, "red_gain": self.red_gain, "blue_gain": self.blue_gain} 45 | 46 | @self.mode.callback() 47 | def func(value): 48 | self.data[self.name]["mode"] = value 49 | main.camera.mode = value 50 | return self.framerate.out_value(main.camera.framerate) + self.framerate.out_min(max(int(main.camera.min_framerate), MIN_FRAMERATE)) + self.framerate.out_max(int(main.camera.max_framerate)) 51 | 52 | @self.brightness.callback() 53 | def func(value): 54 | self.data[self.name]["brightness"] = value 55 | main.camera.brightness = value 56 | 57 | @self.framerate.callback() 58 | def func(value): 59 | self.data[self.name]["framerate"] = value 60 | main.camera.framerate = value 61 | return self.shutter.out_value(main.camera.shutter_speed) + self.shutter.out_max(1/main.camera.framerate) 62 | 63 | @self.autoshutter.callback() 64 | def func(value): 65 | self.data[self.name]["autoshutter"] = value 66 | main.camera.autoshutter = value 67 | return Output(shutter_cont.id, 'is_open', not value) 68 | 69 | @self.shutter.callback() 70 | def func(value): 71 | self.data[self.name]["shutter"] = value 72 | main.camera.shutter_speed = value 73 | 74 | @self.awb.callback() 75 | def func(value): 76 | self.data[self.name]["awb"] = value 77 | main.camera.awb = value 78 | return Output(awb_gains.id, 'is_open', not value) 79 | 80 | @self.red_gain.callback() 81 | def func(value): 82 | self.data[self.name]["red_gain"] = value 83 | main.camera.awb_red = value 84 | 85 | @self.blue_gain.callback() 86 | def func(value): 87 | self.data[self.name]["blue_gain"] = value 88 | main.camera.awb_blue = value 89 | 90 | self.layout = dbc.Collapse([self.mode, self.brightness, self.framerate, self.autoshutter, shutter_cont, self.awb, awb_gains], id=self.kapp.new_id(), is_open=True) 91 | 92 | def settings_update(self, settings): 93 | # Copy settings because setting framerate (for example) sets shutter. 94 | settings = settings.copy() 95 | for k, s in self.settings_map.items(): 96 | try: 97 | # Individually set each setting. This will make sure they are 98 | # set in order, which is important (e.g. shutter needs to be set last.) 99 | self.kapp.push_mods(s.out_value(settings[k])) 100 | except: 101 | pass 102 | return [] 103 | 104 | def data_update(self, changed, cmem=None): 105 | mods = [] 106 | if self.name in changed: 107 | mods += self.settings_update(self.data[self.name]) 108 | return mods 109 | 110 | def focus(self, state): 111 | if state: 112 | return self.main.perspective.out_disp(True) 113 | 114 | def reset(self): 115 | return self.settings_update(self.main.config_consts.DEFAULT_CAMERA_SETTINGS) 116 | 117 | def frame(self): 118 | frame = self.stream.frame() 119 | if frame: 120 | return frame[0] 121 | 122 | -------------------------------------------------------------------------------- /apps/motionscope/centroidtracker.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Vizy 3 | # 4 | # All Vizy source code is provided under the terms of the 5 | # GNU General Public License v2 (http://www.gnu.org/licenses/gpl-2.0.html). 6 | # Those wishing to use Vizy source code, software and/or 7 | # technologies under different licensing terms should contact us at 8 | # support@charmedlabs.com. 9 | # 10 | 11 | # This code was adapted from pyimagesearch.com. 12 | 13 | from scipy.spatial import distance as dist 14 | from collections import OrderedDict 15 | import numpy as np 16 | 17 | class CentroidTracker: 18 | def __init__(self, maxDisappeared=1, maxDistance=50, maxDistanceAdd=50): 19 | # initialize the next unique object ID along with two ordered 20 | # dictionaries used to keep track of mapping a given object 21 | # ID to its centroid and number of consecutive frames it has 22 | # been marked as "disappeared", respectively 23 | self.nextObjectID = 0 24 | self.objects = OrderedDict() 25 | self.disappeared = OrderedDict() 26 | 27 | # store the number of maximum consecutive frames a given 28 | # object is allowed to be marked as "disappeared" until we 29 | # need to deregister the object from tracking 30 | self.maxDisappeared = maxDisappeared 31 | 32 | # store the maximum distance between centroids to associate 33 | # an object -- if the distance is larger than this maximum 34 | # distance we'll start to mark the object as "disappeared" 35 | self.maxDistance = maxDistance 36 | self.maxDistanceAdd = maxDistanceAdd 37 | 38 | def register(self, centroid): 39 | # when registering an object we use the next available object 40 | # ID to store the centroid 41 | self.objects[self.nextObjectID] = centroid 42 | self.disappeared[self.nextObjectID] = 0 43 | self.nextObjectID += 1 44 | 45 | def deregister(self, objectID): 46 | # to deregister an object ID we delete the object ID from 47 | # both of our respective dictionaries 48 | del self.objects[objectID] 49 | del self.disappeared[objectID] 50 | 51 | def objectsSansDisappeared(self): 52 | objects = dict() 53 | for obj in self.objects: 54 | if self.disappeared[obj]==0: 55 | objects[obj] = self.objects[obj] 56 | 57 | return objects 58 | 59 | def update(self, rects, additional=None): 60 | # calculate additional condition 61 | # check to see if the list of input bounding box rectangles 62 | # is empty 63 | if len(rects) == 0: 64 | # loop over any existing tracked objects and mark them 65 | # as disappeared 66 | for objectID in list(self.disappeared.keys()): 67 | self.disappeared[objectID] += 1 68 | 69 | # if we have reached a maximum number of consecutive 70 | # frames where a given object has been marked as 71 | # missing, deregister it 72 | if self.disappeared[objectID] > self.maxDisappeared: 73 | self.deregister(objectID) 74 | 75 | # return early as there are no centroids or tracking info 76 | # to update 77 | return self.objectsSansDisappeared() 78 | 79 | # initialize an array of input centroids for the current frame 80 | if additional is not None: 81 | inputCentroids = np.zeros((len(rects), 6+additional.shape[1])) 82 | else: 83 | inputCentroids = np.zeros((len(rects), 6)) 84 | 85 | # loop over the bounding box rectangles 86 | for (i, (cX, cY, startX, startY, width, height)) in enumerate(rects): 87 | # use the bounding box coordinates to derive the centroid 88 | if additional is not None: 89 | inputCentroids[i] = np.hstack((cX, cY, startX, startY, width, height, additional[i])) 90 | else: 91 | inputCentroids[i] = (cX, cY, startX, startY, width, height) 92 | # if we are currently not tracking any objects take the input 93 | # centroids and register each of them 94 | if len(self.objects) == 0: 95 | for i in range(0, len(inputCentroids)): 96 | self.register(inputCentroids[i]) 97 | 98 | # otherwise, are are currently tracking objects so we need to 99 | # try to match the input centroids to existing object 100 | # centroids 101 | else: 102 | # grab the set of object IDs and corresponding centroids 103 | objectIDs = list(self.objects.keys()) 104 | objectCentroids = np.array(list(self.objects.values())) 105 | 106 | # compute the distance between each pair of object 107 | # centroids and input centroids, respectively -- our 108 | # goal will be to match an input centroid to an existing 109 | # object centroid 110 | if additional is not None: 111 | D1 = dist.cdist(objectCentroids[:, 0:2], inputCentroids[:, 0:2]) 112 | D2 = dist.cdist(objectCentroids[:, 6:], inputCentroids[:, 6:]) 113 | D = D1+D2 114 | else: 115 | D = dist.cdist(objectCentroids[:, 0:2], inputCentroids[:, 0:2]) 116 | 117 | # in order to perform this matching we must (1) find the 118 | # smallest value in each row and then (2) sort the row 119 | # indexes based on their minimum values so that the row 120 | # with the smallest value as at the *front* of the index 121 | # list 122 | rows = D.min(axis=1).argsort() 123 | 124 | # next, we perform a similar process on the columns by 125 | # finding the smallest value in each column and then 126 | # sorting using the previously computed row index list 127 | cols = D.argmin(axis=1)[rows] 128 | 129 | # in order to determine if we need to update, register, 130 | # or deregister an object we need to keep track of which 131 | # of the rows and column indexes we have already examined 132 | usedRows = set() 133 | usedCols = set() 134 | 135 | # loop over the combination of the (row, column) index 136 | # tuples 137 | for (row, col) in zip(rows, cols): 138 | # if we have already examined either the row or 139 | # column value before, ignore it 140 | if row in usedRows or col in usedCols: 141 | continue 142 | 143 | # if the distance between centroids is greater than 144 | # the maximum distance, do not associate the two 145 | # centroids to the same object 146 | if additional is not None: 147 | if D1[row, col] > self.maxDistance: 148 | continue 149 | if D2[row, col] > self.maxDistanceAdd: 150 | continue 151 | else: 152 | if D[row, col] > self.maxDistance: 153 | continue 154 | 155 | # otherwise, grab the object ID for the current row, 156 | # set its new centroid, and reset the disappeared 157 | # counter 158 | objectID = objectIDs[row] 159 | self.objects[objectID] = inputCentroids[col] 160 | self.disappeared[objectID] = 0 161 | 162 | # indicate that we have examined each of the row and 163 | # column indexes, respectively 164 | usedRows.add(row) 165 | usedCols.add(col) 166 | 167 | # compute both the row and column index we have NOT yet 168 | # examined 169 | unusedRows = set(range(0, D.shape[0])).difference(usedRows) 170 | unusedCols = set(range(0, D.shape[1])).difference(usedCols) 171 | 172 | # we need to check and see if some of these objects have 173 | # potentially disappeared 174 | # loop over the unused row indexes 175 | for row in unusedRows: 176 | # grab the object ID for the corresponding row 177 | # index and increment the disappeared counter 178 | objectID = objectIDs[row] 179 | self.disappeared[objectID] += 1 180 | 181 | # check to see if the number of consecutive 182 | # frames the object has been marked "disappeared" 183 | # for warrants deregistering the object 184 | if self.disappeared[objectID] > self.maxDisappeared: 185 | self.deregister(objectID) 186 | 187 | # if the number of input centroids is greater 188 | # than the number of existing object centroids we need to 189 | # register each new input centroid as a trackable object 190 | for col in unusedCols: 191 | self.register(inputCentroids[col]) 192 | 193 | # return the set of trackable objects 194 | return self.objectsSansDisappeared() 195 | 196 | -------------------------------------------------------------------------------- /apps/motionscope/dataupdate.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Vizy 3 | # 4 | # All Vizy source code is provided under the terms of the 5 | # GNU General Public License v2 (http://www.gnu.org/licenses/gpl-2.0.html). 6 | # Those wishing to use Vizy source code, software and/or 7 | # technologies under different licensing terms should contact us at 8 | # support@charmedlabs.com. 9 | # 10 | 11 | class DataUpdate: 12 | def __init__(self, data): 13 | self.data = data 14 | self.data_update_callback_func = None 15 | 16 | # cmem allows the retention of call information to prevent infinite loops, 17 | # feed forward call information, etc. 18 | def data_update(self, changed, cmem=None): 19 | return [] 20 | 21 | def call_data_update_callback(self, changed, cmem=None): 22 | if self.data_update_callback_func: 23 | return self.data_update_callback_func(changed, cmem) 24 | 25 | def data_update_callback(self, func): 26 | self.data_update_callback_func = func 27 | -------------------------------------------------------------------------------- /apps/motionscope/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "MotionScope", 3 | "version": "1.3", 4 | "author": "Charmed Labs", 5 | "email": "support@charmedlabs.com", 6 | "files": ["$VIZY_HOME/etc/motionscope_consts.py", "analyze.py", "camera.py", "capture.py", "centroidtracker.py", "dataupdate.py", "graphs.py", "main.py", "motion.py", "process.py", "simplemotion.py", "tab.py"], 7 | "description": "Captures and analyzes the motion of objects.", 8 | "url": "https://docs.vizycam.com/doku.php?id=wiki:motionscope_app", 9 | "image": "motionscope.jpg" 10 | } -------------------------------------------------------------------------------- /apps/motionscope/motion.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Vizy 3 | # 4 | # All Vizy source code is provided under the terms of the 5 | # GNU General Public License v2 (http://www.gnu.org/licenses/gpl-2.0.html). 6 | # Those wishing to use Vizy source code, software and/or 7 | # technologies under different licensing terms should contact us at 8 | # support@charmedlabs.com. 9 | # 10 | 11 | 12 | class Motion: 13 | 14 | # Extract motion from split BGR input frame (frame_split) 15 | # and split BGR background frame (bg_split) 16 | def extract(self, frame_split, bg_split): 17 | pass 18 | 19 | # Threshold property, varies between 1 and 100 20 | @property 21 | def threshold(self): 22 | return self._threshold 23 | 24 | @threshold.setter 25 | def threshold(self, _threshold): 26 | self._threshold = _threshold 27 | 28 | -------------------------------------------------------------------------------- /apps/motionscope/motionscope.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmedlabs/vizy/59e443cf61150b3b4072c1817665757b1aad611b/apps/motionscope/motionscope.jpg -------------------------------------------------------------------------------- /apps/motionscope/motionscope_consts.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Vizy 3 | # 4 | # All Vizy source code is provided under the terms of the 5 | # GNU General Public License v2 (http://www.gnu.org/licenses/gpl-2.0.html). 6 | # Those wishing to use Vizy source code, software and/or 7 | # technologies under different licensing terms should contact us at 8 | # support@charmedlabs.com. 9 | # 10 | 11 | # External trigger button I/O channel (can be 0, 1, 2, or 3) 12 | EXT_BUTTON_CHANNEL = 0 13 | # Maximum number of seconds to record before stopping 14 | MAX_RECORDING_DURATION = 10 15 | # Maximum width of video window in browser pixels 16 | WIDTH = 736 17 | # Padding between video in browser pixels, controls and sides of browser 18 | PADDING = 10 19 | # Number of graphs to show 20 | GRAPHS = 6 21 | # Start shift recording range in seconds 22 | START_SHIFT = 2 23 | # Minimum total range of object in camera pixels for it to be a valid object 24 | MIN_RANGE = 30 25 | # Default Camera Tab settings 26 | DEFAULT_CAMERA_SETTINGS = {"mode": "768x432x10bpp", "brightness": 50, "framerate": 50, "autoshutter": True, "shutter": 0.0085, "awb": True, "red_gain": 1, "blue_gain": 1} 27 | # Default Capture Tab settings 28 | DEFAULT_CAPTURE_SETTINGS = {"start_shift": 0, "duration": MAX_RECORDING_DURATION, "trigger_mode": "button press", "trigger_sensitivity": 75} 29 | # Default Process settings 30 | DEFAULT_PROCESS_SETTINGS = {"motion_threshold": 25} 31 | 32 | # Don't change the values below unless you know what you're doing or don't mind potentially 33 | # breaking something! 34 | 35 | # Focal length of lens measured in physical camera sensor pixels (unscaled). The focal length 36 | # is needed to make the perspective change (pitch, yaw) accurate. 37 | # The focal length of 2260 assumes the default Vizy wide-angle lens and Sony IMX477 sensor. 38 | FOCAL_LENGTH = 2260 39 | # When playing back recordings, number of frames/second 40 | PLAY_RATE = 30 41 | # When updating time and spacing in Analyze tab, how many updates per second 42 | UPDATE_RATE = 10 43 | # Background frame attenuation factor 44 | BG_AVG_RATIO = 0.1 45 | # Number of frames to feed into filter for background frame 46 | BG_CNT_FINAL = 10 47 | # Default Analyze settings 48 | DEFAULT_ANALYZE_SETTINGS = {"show_options": "objects, points, lines"} 49 | 50 | -------------------------------------------------------------------------------- /apps/motionscope/process.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Vizy 3 | # 4 | # All Vizy source code is provided under the terms of the 5 | # GNU General Public License v2 (http://www.gnu.org/licenses/gpl-2.0.html). 6 | # Those wishing to use Vizy source code, software and/or 7 | # technologies under different licensing terms should contact us at 8 | # support@charmedlabs.com. 9 | # 10 | 11 | import numpy as np 12 | import time 13 | import cv2 14 | from threading import RLock 15 | from tab import Tab 16 | import kritter 17 | from dash_devices.dependencies import Output 18 | import dash_bootstrap_components as dbc 19 | from dash_devices import callback_context 20 | from centroidtracker import CentroidTracker 21 | from simplemotion import SimpleMotion 22 | 23 | PAUSED = 0 24 | PROCESSING = 1 25 | FINISHED = 2 26 | 27 | class Process(Tab): 28 | 29 | def __init__(self, main): 30 | 31 | super().__init__("Process", main.data) 32 | self.main = main 33 | self.lock = RLock() # for sychronizing self.state 34 | self.update_timer = 0 35 | self.camera = main.camera 36 | self.perspective = main.perspective 37 | self.stream = self.camera.stream() 38 | self.data['recording'] = None 39 | self.motion = SimpleMotion() 40 | self.state = PAUSED 41 | self.more = False 42 | 43 | style = {"label_width": 3, "control_width": 6} 44 | self.playback_c = kritter.Kslider(value=0, mxs=(0, 1, .001), updatetext=False, updaterate=0, style={"control_width": 8}) 45 | self.process_button = kritter.Kbutton(name=[self.kapp.icon("refresh"), "Process"], spinner=True) 46 | self.cancel = kritter.Kbutton(name=[self.kapp.icon("close"), "Cancel"], disabled=True) 47 | self.more_c = kritter.Kbutton(name=self.kapp.icon("plus", padding=0)) 48 | self.process_button.append(self.cancel) 49 | self.process_button.append(self.more_c) 50 | 51 | self.motion_threshold_c = kritter.Kslider(name="Motion threshold", mxs=(1, 100, 1), format=lambda val: f'{val:.0f}%', style=style) 52 | 53 | more_controls = dbc.Collapse([self.motion_threshold_c], id=self.kapp.new_id(), is_open=False) 54 | self.layout = dbc.Collapse([self.playback_c, self.process_button, more_controls], id=self.kapp.new_id(), is_open=False) 55 | 56 | @self.more_c.callback() 57 | def func(): 58 | self.more = not self.more 59 | return self.more_c.out_name(self.kapp.icon("minus", padding=0) if self.more else self.kapp.icon("plus", padding=0)) + [Output(more_controls.id, "is_open", self.more)] 60 | 61 | @self.motion_threshold_c.callback() 62 | def func(val): 63 | self.data[self.name]["motion_threshold"] = val 64 | self.motion.threshold = val 65 | 66 | @self.process_button.callback() 67 | def func(): 68 | return self.set_state(PROCESSING) 69 | 70 | @self.cancel.callback() 71 | def func(): 72 | with self.lock: 73 | self.obj_data.clear() 74 | return self.set_state(PAUSED) 75 | 76 | @self.playback_c.callback() 77 | def func(t): 78 | if callback_context.client: 79 | t = self.data['recording'].time_seek(t) 80 | self.curr_frame = self.data['recording'].frame() 81 | time.sleep(1/self.main.config_consts.UPDATE_RATE) 82 | return self.playback_c.out_text(f"{t:.3f}s") 83 | 84 | def settings_update(self, settings): 85 | mods = [] 86 | try: 87 | mods += self.motion_threshold_c.out_value(settings['motion_threshold']) 88 | except: 89 | pass 90 | return mods 91 | 92 | def data_update(self, changed, cmem=None): 93 | with self.lock: 94 | mods = [] 95 | if "obj_data" in changed and cmem is None: 96 | self.obj_data = self.data['obj_data'] 97 | mods += self.set_state(FINISHED, 1) 98 | if "recording" in changed: 99 | # If we're loading, calculate bg immediately. 100 | if cmem is None: 101 | self.calc_bg() 102 | # ...otherwise defer it until we are processing so we don't 103 | # block UI. 104 | else: 105 | self.bg_split = None 106 | mods += self.set_state(PROCESSING, 1) 107 | if self.name in changed: 108 | mods += self.settings_update(self.data[self.name]) 109 | return mods 110 | 111 | def record(self, tinfo, pts, index): 112 | for i, v in tinfo.items(): 113 | v = v[0:6] 114 | v = np.insert(v, 0, pts) 115 | v = np.insert(v, 1, index) 116 | if i in self.obj_data: 117 | self.obj_data[i] = np.vstack((self.obj_data[i], v)) 118 | else: 119 | self.obj_data[i] = np.array([v]) 120 | 121 | def calc_bg(self): 122 | self.data['recording'].seek(0) 123 | for i in range(self.main.config_consts.BG_CNT_FINAL): 124 | frame = self.data['recording'].frame()[0] 125 | if i==0: 126 | self.bg = frame 127 | else: 128 | self.bg = self.bg*(1-self.main.config_consts.BG_AVG_RATIO) + frame*self.main.config_consts.BG_AVG_RATIO 129 | self.bg = self.bg.astype("uint8") 130 | self.data['recording'].seek(0) 131 | # We only use split version of bg 132 | self.bg_split = cv2.split(self.bg) 133 | self.data['bg'] = self.bg 134 | 135 | def prune(self): 136 | # Delete objects that don't move "much" (set by MIN_RANGE) 137 | # Go through data find x and y range, if both ranges are less than 138 | # threshold then delete. 139 | for i, data in self.obj_data.copy().items(): 140 | x_range = np.max(data[:, 2]) - np.min(data[:, 2]) 141 | y_range = np.max(data[:, 3]) - np.min(data[:, 3]) 142 | if x_range1/self.main.config_consts.UPDATE_RATE: 231 | self.update_timer = t 232 | mods = self.update() 233 | if mods: 234 | self.kapp.push_mods(mods) 235 | 236 | if self.curr_frame is None: 237 | return None 238 | 239 | frame = self.process(self.curr_frame) 240 | 241 | return frame 242 | 243 | def reset(self): 244 | return self.settings_update(self.main.config_consts.DEFAULT_PROCESS_SETTINGS) 245 | 246 | def focus(self, state): 247 | mods = [] 248 | if state: 249 | mods += self.perspective.out_disp(False) 250 | self.stream.stop() 251 | # If we don't have any object data, we should go ahead and try to process 252 | if not self.obj_data: 253 | mods += self.set_state(PROCESSING) 254 | # If we lose focus during processing, clear data, set state to paused. 255 | elif self.state==PROCESSING: 256 | with self.lock: 257 | self.obj_data.clear() 258 | return self.set_state(PAUSED) 259 | return mods 260 | 261 | -------------------------------------------------------------------------------- /apps/motionscope/simplemotion.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Vizy 3 | # 4 | # All Vizy source code is provided under the terms of the 5 | # GNU General Public License v2 (http://www.gnu.org/licenses/gpl-2.0.html). 6 | # Those wishing to use Vizy source code, software and/or 7 | # technologies under different licensing terms should contact us at 8 | # support@charmedlabs.com. 9 | # 10 | 11 | import numpy as np 12 | import cv2 13 | from motion import Motion 14 | from kritter import Range 15 | 16 | # This class does reasonably well with symmetrical(ish) objects as they 17 | # move against the background. If you wanted to track the motion of a 18 | # trebuchet (for example) you'd want to use other visual clues (hue? structure?) 19 | # to extract the motion of the munition only as it's being fired and gaining speed. 20 | class SimpleMotion(Motion): 21 | 22 | def __init__(self): 23 | # Range maps one range to another range -- in this case from 1 to 100 24 | # which is user-friendly compared to 1*3 to 50*3, which makes sense for our threshold code. 25 | self.threshold_range = Range((1, 100), (1*3, 50*3), outval=20*3) 26 | 27 | def extract(self, frame_split, bg_split): 28 | diff = np.zeros(frame_split[0].shape, dtype="uint16") 29 | 30 | # Compute absolute difference with background frame 31 | for i in range(3): 32 | diff += cv2.absdiff(bg_split[i], frame_split[i]) 33 | 34 | # Threshold motion 35 | motion = diff>self.threshold_range.outval 36 | motion = motion.astype("uint8") 37 | 38 | # Clean up 39 | motion = cv2.erode(motion, None, iterations=4) 40 | motion = cv2.dilate(motion, None, iterations=4) 41 | 42 | return motion 43 | 44 | @property 45 | def threshold(self): 46 | return self.threshold_range.inval 47 | 48 | @threshold.setter 49 | def threshold(self, _threshold): 50 | self.threshold_range.inval = _threshold 51 | -------------------------------------------------------------------------------- /apps/motionscope/tab.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Vizy 3 | # 4 | # All Vizy source code is provided under the terms of the 5 | # GNU General Public License v2 (http://www.gnu.org/licenses/gpl-2.0.html). 6 | # Those wishing to use Vizy source code, software and/or 7 | # technologies under different licensing terms should contact us at 8 | # support@charmedlabs.com. 9 | # 10 | 11 | from kritter import Kritter 12 | from dataupdate import DataUpdate 13 | 14 | class Tab(DataUpdate): 15 | def __init__(self, name, data, kapp=None): 16 | super().__init__(data) 17 | self.name = name 18 | self.kapp = kapp if kapp else Kritter.kapp 19 | self.focused = False 20 | 21 | def frame(self): 22 | return None 23 | 24 | def focus(self, state): 25 | self.focused = state 26 | return [] 27 | 28 | def reset(self): 29 | return [] -------------------------------------------------------------------------------- /apps/object_detector/handlers.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Vizy 3 | # 4 | # All Vizy source code is provided under the terms of the 5 | # GNU General Public License v2 (http://www.gnu.org/licenses/gpl-2.0.html). 6 | # Those wishing to use Vizy source code, software and/or 7 | # technologies under different licensing terms should contact us at 8 | # support@charmedlabs.com. 9 | # 10 | 11 | from kritter.ktextvisor import KtextVisor, KtextVisorTable, Image, Video 12 | 13 | # This gets called when a noteworthy event happens. 14 | # You can insert your own code here :) 15 | def handle_event(self, event): 16 | print(f"handle_event: {event}") 17 | # Deal with "trigger" events 18 | if event['event_type']=='trigger': 19 | if self.tv: 20 | # Send text message with timestamp, detected object class, and curated image 21 | self.tv.send([f"{event['timestamp']} {event['class']}", Image(event['image'])]) 22 | 23 | # This gets called when Vizy gets a text message (Telegram). 24 | # You can insert your own code here :) 25 | def handle_text(self, words, sender, context): 26 | print(f"handle_text from {sender}: {words}, context: {context}") 27 | -------------------------------------------------------------------------------- /apps/object_detector/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Object Detector", 3 | "version": "2.0", 4 | "author": "Charmed Labs", 5 | "email": "support@charmedlabs.com", 6 | "files": ["main.py", "handlers.py", "$VIZY_HOME/etc/object_detector_consts.py"], 7 | "description": "Detects objects, logs detections, sends text updates.", 8 | "url": "https://docs.vizycam.com/doku.php?id=wiki:object_detector_app", 9 | "image": "object_detector.jpg" 10 | } -------------------------------------------------------------------------------- /apps/object_detector/object_detector.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmedlabs/vizy/59e443cf61150b3b4072c1817665757b1aad611b/apps/object_detector/object_detector.jpg -------------------------------------------------------------------------------- /apps/object_detector/object_detector_consts.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Vizy 3 | # 4 | # All Vizy source code is provided under the terms of the 5 | # GNU General Public License v2 (http://www.gnu.org/licenses/gpl-2.0.html). 6 | # Those wishing to use Vizy source code, software and/or 7 | # technologies under different licensing terms should contact us at 8 | # support@charmedlabs.com. 9 | # 10 | 11 | # Number of images to keep in the media directory 12 | IMAGES_KEEP = 100 13 | # Number of images to display in the media queue 14 | IMAGES_DISPLAY = 25 15 | # How long to wait (seconds) before picking best detection image for media queue 16 | PICKER_TIMEOUT = 10 17 | # Width of media queue images 18 | MEDIA_QUEUE_IMAGE_WIDTH = 300 19 | # Folder within Google Photos to save media 20 | GPHOTO_ALBUM = "Vizy Object Detector" 21 | 22 | # Maximum distance between detections before assuming they are different 23 | # detections (birds) 24 | TRACKER_DISAPPEARED_DISTANCE = 300 25 | # Numbers of frames that a detection has disappeared before assuming it's gone 26 | TRACKER_MAX_DISAPPEARED = 1 27 | -------------------------------------------------------------------------------- /apps/object_detector/train_detector.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 2, 6 | "metadata": { 7 | "colab": { 8 | "base_uri": "https://localhost:8080/" 9 | }, 10 | "id": "gq3E08vWXsGv", 11 | "outputId": "49597391-d635-46df-9b9f-97ff285d8dbd" 12 | }, 13 | "outputs": [], 14 | "source": [ 15 | "from google.colab import drive\n", 16 | "drive.mount('/content/drive')\n", 17 | "\n", 18 | "!pip install folium==0.2.1\n", 19 | "!pip install tensorflow==2.8\n", 20 | "!apt install --allow-change-held-packages libcudnn8=8.1.0.77-1+cuda11.2\n", 21 | "!pip install -q tflite-model-maker\n", 22 | "!pip install -q tflite-support\n", 23 | "\n", 24 | "import numpy as np\n", 25 | "import os\n", 26 | "\n", 27 | "from tflite_model_maker.config import ExportFormat, QuantizationConfig\n", 28 | "from tflite_model_maker import model_spec\n", 29 | "from tflite_model_maker import object_detector\n", 30 | "\n", 31 | "from tflite_support import metadata\n", 32 | "\n", 33 | "import tensorflow as tf\n", 34 | "assert tf.__version__.startswith('2')\n", 35 | "\n", 36 | "tf.get_logger().setLevel('ERROR')\n", 37 | "from absl import logging\n", 38 | "logging.set_verbosity(logging.ERROR)" 39 | ] 40 | }, 41 | { 42 | "cell_type": "code", 43 | "execution_count": 3, 44 | "metadata": { 45 | "id": "-2Bh6hnDb347" 46 | }, 47 | "outputs": [], 48 | "source": [ 49 | "!unzip -q \"$PROJECT_DIR/training_set.zip\"" 50 | ] 51 | }, 52 | { 53 | "cell_type": "code", 54 | "execution_count": 7, 55 | "metadata": { 56 | "id": "HBAfF82Q_rUo", 57 | "colab": { 58 | "base_uri": "https://localhost:8080/", 59 | "height": 452 60 | }, 61 | "outputId": "44a4ccd5-5605-4a53-8687-0139b225a433" 62 | }, 63 | "outputs": [], 64 | "source": [ 65 | "import json\n", 66 | "with open(os.path.join(PROJECT_DIR, f\"{PROJECT_NAME}.json\")) as file:\n", 67 | " info = json.load(file)\n", 68 | "\n", 69 | "train_data = object_detector.DataLoader.from_pascal_voc(\n", 70 | " '/content/train',\n", 71 | " '/content/train',\n", 72 | " info['classes']\n", 73 | ")\n", 74 | "\n", 75 | "val_data = object_detector.DataLoader.from_pascal_voc(\n", 76 | " '/content/validate',\n", 77 | " '/content/validate',\n", 78 | " info['classes']\n", 79 | ")" 80 | ] 81 | }, 82 | { 83 | "cell_type": "code", 84 | "execution_count": 6, 85 | "metadata": { 86 | "colab": { 87 | "base_uri": "https://localhost:8080/", 88 | "height": 397 89 | }, 90 | "id": "uW7AsxXwc0r8", 91 | "outputId": "881af708-96bd-483b-eaee-9d843abb4001" 92 | }, 93 | "outputs": [], 94 | "source": [ 95 | "spec = model_spec.get('efficientdet_lite0')\n", 96 | "model = object_detector.create(train_data, model_spec=spec, batch_size=4, train_whole_model=True, epochs=20, validation_data=val_data)" 97 | ] 98 | }, 99 | { 100 | "cell_type": "code", 101 | "execution_count": null, 102 | "metadata": { 103 | "id": "BgxyZmmwgQiN" 104 | }, 105 | "outputs": [], 106 | "source": [ 107 | "model.evaluate(val_data)" 108 | ] 109 | }, 110 | { 111 | "cell_type": "code", 112 | "execution_count": null, 113 | "metadata": { 114 | "id": "qNeI-dt4gUU9" 115 | }, 116 | "outputs": [], 117 | "source": [ 118 | "model.export(export_dir='.', tflite_filename=f'{PROJECT_NAME}.tflite')" 119 | ] 120 | }, 121 | { 122 | "cell_type": "code", 123 | "execution_count": null, 124 | "metadata": { 125 | "id": "vW3ycrL1gW-8" 126 | }, 127 | "outputs": [], 128 | "source": [ 129 | "model.evaluate_tflite(f'{PROJECT_NAME}.tflite', val_data)" 130 | ] 131 | }, 132 | { 133 | "cell_type": "code", 134 | "source": [ 135 | "!cp \"$PROJECT_NAME\"'.tflite' \"$PROJECT_DIR\"" 136 | ], 137 | "metadata": { 138 | "id": "GSbIudVTwE0w" 139 | }, 140 | "execution_count": null, 141 | "outputs": [] 142 | } 143 | ], 144 | "metadata": { 145 | "accelerator": "GPU", 146 | "colab": { 147 | "collapsed_sections": [], 148 | "machine_shape": "hm", 149 | "provenance": [] 150 | }, 151 | "gpuClass": "standard", 152 | "kernelspec": { 153 | "display_name": "Python 3", 154 | "name": "python3" 155 | }, 156 | "language_info": { 157 | "name": "python" 158 | } 159 | }, 160 | "nbformat": 4, 161 | "nbformat_minor": 0 162 | } -------------------------------------------------------------------------------- /apps/radar/font.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmedlabs/vizy/59e443cf61150b3b4072c1817665757b1aad611b/apps/radar/font.ttf -------------------------------------------------------------------------------- /apps/radar/handlers.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Vizy 3 | # 4 | # All Vizy source code is provided under the terms of the 5 | # GNU General Public License v2 (http://www.gnu.org/licenses/gpl-2.0.html). 6 | # Those wishing to use Vizy source code, software and/or 7 | # technologies under different licensing terms should contact us at 8 | # support@charmedlabs.com. 9 | # 10 | 11 | from kritter.ktextvisor import KtextVisor, KtextVisorTable, Image, Video 12 | 13 | # This gets called when a noteworthy event happens. 14 | # You can insert your own code here :) 15 | def handle_event(self, event): 16 | print(f"handle_event: {event}") 17 | # Deal with "trigger" events 18 | if event['event_type']=='trigger': 19 | if self.tv: 20 | # Send text message with timestamp, detected object class, and curated image 21 | self.tv.send([f"{event['timestamp']} {event['class']}", Image(event['image'])]) 22 | 23 | # This gets called when Vizy gets a text message (Telegram). 24 | # You can insert your own code here :) 25 | def handle_text(self, words, sender, context): 26 | print(f"handle_text from {sender}: {words}, context: {context}") 27 | -------------------------------------------------------------------------------- /apps/radar/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Radar", 3 | "author": "Charmed Labs", 4 | "email": "support@charmedlabs.com", 5 | "version": "1.0", 6 | "description": "Logs the speed of vehicles and identifies \"speeders\".", 7 | "files": ["main.py", "radar_consts.py", "handlers.py"], 8 | "url": "https://docs.vizycam.com/doku.php?id=wiki:radar_app", 9 | "image": "radar.jpg" 10 | } 11 | -------------------------------------------------------------------------------- /apps/radar/radar.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmedlabs/vizy/59e443cf61150b3b4072c1817665757b1aad611b/apps/radar/radar.jpg -------------------------------------------------------------------------------- /apps/radar/radar_consts.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Vizy 3 | # 4 | # All Vizy source code is provided under the terms of the 5 | # GNU General Public License v2 (http://www.gnu.org/licenses/gpl-2.0.html). 6 | # Those wishing to use Vizy source code, software and/or 7 | # technologies under different licensing terms should contact us at 8 | # support@charmedlabs.com. 9 | # 10 | 11 | # Name of Google Photos album to store photos 12 | ALBUM = "radar" 13 | # Mimimum value before differences in pixel values are considered motion 14 | NOISE_FLOOR = 30*3 15 | # Maximum amount of time a vehicle can take to traverse the width of the image 16 | DATA_TIMEOUT = 10 # seconds 17 | # Number of seconds the speed is displayed on the video window after a vehicle's speed is measured 18 | SPEED_DISPLAY_TIMEOUT = 3 # seconds 19 | # Font size to overlay the speed on top of the video/image 20 | FONT_SIZE = 60 21 | # Color to overlay speed 22 | FONT_COLOR = (0, 255, 0) 23 | # Color to overlay speed if speed limit is exceeded 24 | FONT_COLOR_EXCEED = (0, 0, 255) 25 | # Minimum number of data points for a valid vehicle detection 26 | MINIMUM_DATA = 3 27 | # Camera shutter speed (seconds) 28 | SHUTTER_SPEED = 0.001 29 | # Camera shutter speed in low light conditions (seconds) 30 | LOW_LIGHT_SHUTTER_SPEED = 1/30 31 | # Maximum least-squares fitting error per data point for a valid vehicle detection 32 | MAX_RESIDUAL = 100 33 | -------------------------------------------------------------------------------- /examples/edge_detection/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Edge Detection", 3 | "author": "Charmed Labs", 4 | "email": "support@charmedlabs.com", 5 | "description": "Simple OpenCV example that detects edges." 6 | } 7 | -------------------------------------------------------------------------------- /examples/edge_detection/main.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Vizy 3 | # 4 | # All Vizy source code is provided under the terms of the 5 | # GNU General Public License v2 (http://www.gnu.org/licenses/gpl-2.0.html). 6 | # Those wishing to use Vizy source code, software and/or 7 | # technologies under different licensing terms should contact us at 8 | # support@charmedlabs.com. 9 | # 10 | 11 | import cv2 12 | from threading import Thread 13 | from vizy import Vizy 14 | import kritter 15 | import time 16 | 17 | class EdgeDetector: 18 | 19 | def __init__(self): 20 | self.threshold1 = 100 21 | self.threshold2 = 200 22 | camera = kritter.Camera(hflip=True, vflip=True) 23 | self.stream = camera.stream() 24 | kapp = Vizy() 25 | 26 | self.video = kritter.Kvideo(width=camera.resolution[0]) 27 | threshold1_slider = kritter.Kslider(name="threshold1", value=self.threshold1, mxs=(0, 255, 1)) 28 | threshold1_slider.t0 = 0 29 | threshold2_slider = kritter.Kslider(name="threshold2", value=self.threshold2, mxs=(0, 255, 1)) 30 | threshold2_slider.t0 = 0 31 | kapp.layout = [self.video, threshold1_slider, threshold2_slider] 32 | 33 | @threshold1_slider.callback() 34 | def func(val): 35 | self.threshold1 = val 36 | if self.threshold1>self.threshold2 and time.time()-threshold2_slider.t0>1: 37 | threshold1_slider.t0 = time.time() 38 | self.threshold2 = self.threshold1 39 | return threshold2_slider.out_value(self.threshold1) 40 | 41 | @threshold2_slider.callback() 42 | def func(val): 43 | self.threshold2 = val 44 | if self.threshold21: 45 | threshold2_slider.t0 = time.time() 46 | self.threshold1 = self.threshold2 47 | return threshold1_slider.out_value(self.threshold2) 48 | 49 | self.run_loop = True 50 | thread = Thread(target=self.loop) 51 | thread.start() 52 | 53 | # Run Kritter server, which blocks. 54 | kapp.run() 55 | self.run_loop = False 56 | 57 | # Main processing thread 58 | def loop(self): 59 | while self.run_loop: 60 | # Get frame 61 | frame = self.stream.frame()[0] 62 | # Convert to grayscale 63 | gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) 64 | # Create edge image 65 | edges = cv2.Canny(gray, self.threshold1, self.threshold2) 66 | # Display 67 | self.video.push_frame(edges) 68 | 69 | 70 | if __name__ == '__main__': 71 | ed = EdgeDetector() 72 | -------------------------------------------------------------------------------- /examples/pet_companion/hey.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmedlabs/vizy/59e443cf61150b3b4072c1817665757b1aad611b/examples/pet_companion/hey.wav -------------------------------------------------------------------------------- /examples/pet_companion/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Pet Companion", 3 | "version": "1.0", 4 | "description": "Keeps your pet company!", 5 | "url": "https://docs.vizycam.com/doku.php?id=wiki:pet_companion2" 6 | } -------------------------------------------------------------------------------- /examples/pet_companion/main.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import kritter 3 | import vizy 4 | from subprocess import run 5 | import os 6 | from kritter.tflite import TFliteDetector 7 | import time 8 | 9 | MEDIA_DIR = os.path.join(os.path.dirname(__file__), "media") 10 | PIC_TIMEOUT = 10 11 | PIC_ALBUM = "Vizy Pet Companion" 12 | 13 | class PetCompanion: 14 | 15 | def __init__(self): 16 | 17 | # Set up Vizy class, Camera, etc. 18 | self.kapp = vizy.Vizy() 19 | self.camera = kritter.Camera(hflip=True, vflip=True) 20 | self.stream = self.camera.stream() 21 | self.pic_timer = 0 22 | self.kapp.power_board.vcc12(False) # Turn off 23 | 24 | # Put components in the layout 25 | self.video = kritter.Kvideo(width=800, overlay=True) 26 | brightness = kritter.Kslider(name="Brightness", value=self.camera.brightness, mxs=(0, 100, 1), format=lambda val: '{}%'.format(val), grid=False) 27 | call_pet_button = kritter.Kbutton(name="Call pet") 28 | dispense_treat_button = kritter.Kbutton(name="Dispense treat") 29 | self.kapp.layout = [self.video, brightness, call_pet_button, dispense_treat_button] 30 | 31 | # Start TensorFlow 32 | self.tflite = TFliteDetector(threshold=0.5) 33 | 34 | # Set up Google Photos and media queue 35 | gcloud = kritter.Gcloud(self.kapp.etcdir) 36 | gpsm = kritter.GPstoreMedia(gcloud) 37 | self.media_q = kritter.SaveMediaQueue(gpsm, MEDIA_DIR) 38 | 39 | @brightness.callback() 40 | def func(value): 41 | self.camera.brightness = value 42 | 43 | @call_pet_button.callback() 44 | def func(): 45 | print("Calling pet...") 46 | audio_file = os.path.join(os.path.dirname(__file__), "hey.wav") 47 | run(["omxplayer", audio_file]) 48 | 49 | @dispense_treat_button.callback() 50 | def func(): 51 | print("Dispensing treat...") 52 | self.kapp.power_board.vcc12(True) # Turn on 53 | time.sleep(0.5) # Wait a little to give solenoid time to dispense treats 54 | self.kapp.power_board.vcc12(False) # Turn off 55 | 56 | # Run camera grab thread. 57 | self.run_grab = True 58 | threading.Thread(target=self.grab).start() 59 | 60 | # Run Vizy webserver, which blocks. 61 | self.kapp.run() 62 | self.run_grab = False 63 | self.media_q.close() 64 | 65 | def upload_pic(self, image): 66 | t = time.time() 67 | # Save picture if timer expires 68 | if t-self.pic_timer>PIC_TIMEOUT: 69 | self.pic_timer = t 70 | print("Uploading pic...") 71 | self.media_q.store_image_array(image, album=PIC_ALBUM) 72 | 73 | def filter_detected(self, dets): 74 | # Discard detections other than dogs and cats. 75 | dets = [det for det in dets if det['class']=="dog" or det['class']=="cat"] 76 | return dets 77 | 78 | def grab(self): 79 | while self.run_grab: 80 | # Get frame 81 | frame = self.stream.frame()[0] 82 | # Run TensorFlow detector 83 | dets = self.tflite.detect(frame) 84 | # If we detect something... 85 | if dets is not None: 86 | self.kapp.push_mods(kritter.render_detected(self.video.overlay, dets)) 87 | dets = self.filter_detected(dets) 88 | # Save picture if we still see something after filtering 89 | if dets: 90 | self.upload_pic(frame) 91 | # Send frame 92 | self.video.push_frame(frame) 93 | 94 | if __name__ == "__main__": 95 | PetCompanion() -------------------------------------------------------------------------------- /examples/pictaker/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Pic Taker", 3 | "author": "Charmed Labs", 4 | "email": "support@charmedlabs.com", 5 | "description": "Simple example that takes long exposure pictures, mostly for use in with a telescope." 6 | } 7 | -------------------------------------------------------------------------------- /examples/pictaker/main.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Vizy 3 | # 4 | # All Vizy source code is provided under the terms of the 5 | # GNU General Public License v2 (http://www.gnu.org/licenses/gpl-2.0.html). 6 | # Those wishing to use Vizy source code, software and/or 7 | # technologies under different licensing terms should contact us at 8 | # support@charmedlabs.com. 9 | # 10 | 11 | import os 12 | import time 13 | import datetime 14 | import subprocess 15 | from threading import Thread 16 | import kritter 17 | from dash_devices.dependencies import Output 18 | import dash_bootstrap_components as dbc 19 | import dash_html_components as html 20 | from vizy import Vizy 21 | 22 | BASE_DIR = os.path.dirname(os.path.realpath(__file__)) 23 | MEDIA_DIR = os.path.join(BASE_DIR, "media") 24 | 25 | MOST_RECENT = 0 26 | PREVIOUS = 1 27 | NEXT = 2 28 | 29 | class TakePic: 30 | 31 | def __init__(self): 32 | # Set up vizy class, config files. 33 | self.kapp = Vizy() 34 | if not os.path.exists(MEDIA_DIR): 35 | os.mkdir(MEDIA_DIR) 36 | # Create and start camera. 37 | self.camera = kritter.Camera(hflip=True, vflip=True) 38 | self.stream = self.camera.stream() 39 | self.take_pic = False 40 | self.camera.mode = "2016x1520x10bpp" 41 | self.shutter = 1 42 | self.gain = 1 43 | 44 | style = {"label_width": 3, "control_width": 6} 45 | self.mode_c = kritter.Kradio(options=["Preview", "View"], value="Preview", style=style) 46 | self.video = kritter.Kvideo(width=self.camera.resolution[0]) 47 | self.brightness = kritter.Kslider(name="Preview brightness", value=self.camera.brightness, mxs=(0, 100, 1), format=lambda val: '{}%'.format(val), style=style) 48 | self.framerate = kritter.Kslider(name="Preview framerate", value=self.camera.framerate, mxs=(self.camera.min_framerate, self.camera.max_framerate, 1), format=lambda val : '{} fps'.format(val), style=style) 49 | 50 | self.disp_pic = html.Img(id=self.kapp.new_id(), style={"max-width": "4056px", "width": "100%", "height": "100%"}) 51 | self.pic_div = html.Div(self.disp_pic, id=self.kapp.new_id(), style={"display": "none"}) 52 | self.prev_pic = kritter.Kbutton(name=self.kapp.icon("step-backward", padding=0)) 53 | self.next_pic = kritter.Kbutton(name=self.kapp.icon("step-forward", padding=0)) 54 | self.pic_info = kritter.Ktext() 55 | 56 | self.ir_filter = kritter.Kcheckbox(name='IR filter', grid=False, value=self.kapp.power_board.ir_filter(), style=style) 57 | 58 | self.pic = kritter.Kbutton(name="Take pic", spinner=True) 59 | self.shutter_c = kritter.Kslider(name="Shutter", value=self.shutter, mxs=(.01, 30, .001), format=lambda val : f'{val:.3f}s', style=style) 60 | self.gain_c = kritter.Kslider(name="Gain", value=self.gain, mxs=(.1, 100, .01), style=style) 61 | 62 | self.preview = dbc.Collapse(dbc.Card([self.video, self.brightness, self.framerate, self.shutter_c, self.gain_c]), is_open=True, id=self.kapp.new_id()) 63 | self.prev_pic.append(self.next_pic) 64 | self.view = dbc.Collapse(dbc.Card([self.pic_div, self.pic_info, self.prev_pic]), is_open=False, id=self.kapp.new_id()) 65 | 66 | self.pic.append(self.ir_filter) 67 | self.kapp.layout = html.Div([self.preview, self.view, self.mode_c, self.pic], style={"margin": "15px"}) 68 | 69 | @self.brightness.callback() 70 | def func(value): 71 | self.camera.brightness = value 72 | 73 | @self.framerate.callback() 74 | def func(value): 75 | self.camera.framerate = value 76 | 77 | @self.ir_filter.callback() 78 | def func(value): 79 | self.kapp.power_board.ir_filter(value) 80 | 81 | @self.prev_pic.callback() 82 | def func(): 83 | return self.show_pic(PREVIOUS) 84 | 85 | @self.next_pic.callback() 86 | def func(): 87 | return self.show_pic(NEXT) 88 | 89 | @self.pic.callback() 90 | def func(): 91 | self.take_pic = True 92 | 93 | @self.shutter_c.callback() 94 | def func(val): 95 | self.shutter = val 96 | 97 | @self.gain_c.callback() 98 | def func(val): 99 | print("gain", val) 100 | self.gain = val 101 | 102 | @self.mode_c.callback() 103 | def func(val): 104 | return [Output(self.preview.id, "is_open", val=="Preview"), Output(self.view.id, "is_open", val=="View")] 105 | 106 | # Add our own media path 107 | self.kapp.media_path.insert(0, MEDIA_DIR) 108 | 109 | self.kapp.push_mods(self.show_pic(MOST_RECENT)) 110 | self.kapp.push_mods(self.gain_c.out_value(2)) 111 | 112 | # Run camera grab thread. 113 | self.run_grab = True 114 | grab_thread = Thread(target=self.grab) 115 | grab_thread.start() 116 | 117 | # Run Kritter server, which blocks. 118 | self.kapp.run() 119 | self.run_grab = False 120 | 121 | def show_pic(self, which): 122 | pics = os.listdir(MEDIA_DIR) 123 | pics = sorted(pics) 124 | if which==MOST_RECENT: 125 | if len(pics)==0: 126 | return 127 | index = len(pics)-1 128 | elif which==PREVIOUS: 129 | index = pics.index(self.curr_pic)-1 130 | if index<0: 131 | return 132 | elif which==NEXT: 133 | index = pics.index(self.curr_pic)+1 134 | if index>=len(pics): 135 | return 136 | self.curr_pic = pics[index] 137 | return [Output(self.disp_pic.id, "src", f"media/{self.curr_pic}"), Output(self.pic_div.id, "style", {"display": "block"})] + self.prev_pic.out_disabled(index==0) + self.next_pic.out_disabled(index==len(pics)-1) + self.pic_info.out_value(self.curr_pic) 138 | 139 | def grab(self): 140 | env = os.environ.copy() 141 | del env['LIBCAMERA_IPA_MODULE_PATH'] 142 | while self.run_grab: 143 | # Get frame 144 | frame = self.stream.frame() 145 | # Send frame 146 | self.video.push_frame(frame) 147 | 148 | if self.take_pic: 149 | filename = datetime.datetime.now().strftime("media/%Y_%m_%d_%H_%M_%S.jpg") 150 | self.stream.stop() 151 | self.kapp.push_mods(self.pic.out_spinner_disp(True)) 152 | subprocess.run(["libcamera-still", "-n", "--hflip", "--vflip", "-o", os.path.join(BASE_DIR, filename), "--shutter", f"{int(self.shutter*1000000)}", "--gain", f"{self.gain:.2f}"], env=env) 153 | self.kapp.push_mods(self.pic.out_spinner_disp(False) + self.show_pic(MOST_RECENT) + self.mode_c.out_value("View")) 154 | self.take_pic = False 155 | 156 | 157 | if __name__ == "__main__": 158 | tp = TakePic() -------------------------------------------------------------------------------- /examples/tflite/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "TensorFlow Lite", 3 | "author": "Charmed Labs", 4 | "email": "support@charmedlabs.com", 5 | "description": "Simple TensorFlow Lite demo." 6 | } 7 | -------------------------------------------------------------------------------- /examples/tflite/main.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Vizy 3 | # 4 | # All Vizy source code is provided under the terms of the 5 | # GNU General Public License v2 (http://www.gnu.org/licenses/gpl-2.0.html). 6 | # Those wishing to use Vizy source code, software and/or 7 | # technologies under different licensing terms should contact us at 8 | # support@charmedlabs.com. 9 | # 10 | 11 | from threading import Thread 12 | from vizy import Vizy 13 | from kritter import Camera, Kvideo, Kslider, render_detected 14 | from kritter.tflite import TFliteDetector 15 | import time 16 | 17 | class TFliteExample: 18 | 19 | def __init__(self): 20 | # Instantiate Vizy's camera and camera stream 21 | camera = Camera(hflip=True, vflip=True) 22 | self.stream = camera.stream() 23 | # Initialize detection sensitivity (50%) 24 | self.sensitivity = 0.50 25 | # Instantiate Vizy server, video object, and sensitivity slider 26 | self.kapp = Vizy() 27 | self.video = Kvideo(width=camera.resolution[0], overlay=True) 28 | sensitivity_c = Kslider(name="Sensitivity", value=self.sensitivity*100, mxs=(10, 90, 1), format=lambda val: f'{int(val)}%', grid=False) 29 | # Set application layout 30 | self.kapp.layout = [self.video, sensitivity_c] 31 | 32 | # Callback for sensitivity slider 33 | @sensitivity_c.callback() 34 | def func(value): 35 | # Update sensitivity value, convert from % 36 | self.sensitivity = value/100 37 | 38 | # Instantiate TensorFlow Lite detector 39 | self.tflite = TFliteDetector() 40 | 41 | # Start processing thread 42 | self.run_process = True 43 | Thread(target=self.process).start() 44 | 45 | # Run Vizy server, which blocks. 46 | self.kapp.run() 47 | self.run_process = False 48 | 49 | # Frame processing thread 50 | def process(self): 51 | while self.run_process: 52 | # Get frame 53 | frame = self.stream.frame()[0] 54 | # Run detection 55 | t0 = time.time() 56 | dets = self.tflite.detect(frame, self.sensitivity) 57 | print(time.time()-t0) 58 | # If we detect something... 59 | if dets is not None: 60 | self.kapp.push_mods(render_detected(self.video.overlay, dets)) 61 | # Push frame to the video window in browser. 62 | self.video.push_frame(frame) 63 | 64 | 65 | if __name__ == '__main__': 66 | TFliteExample() 67 | -------------------------------------------------------------------------------- /examples/video/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Video", 3 | "author": "Charmed Labs", 4 | "email": "support@charmedlabs.com", 5 | "description": "Simple video streaming and camera parameter modifying example." 6 | } 7 | -------------------------------------------------------------------------------- /examples/video/main.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Vizy 3 | # 4 | # All Vizy source code is provided under the terms of the 5 | # GNU General Public License v2 (http://www.gnu.org/licenses/gpl-2.0.html). 6 | # Those wishing to use Vizy source code, software and/or 7 | # technologies under different licensing terms should contact us at 8 | # support@charmedlabs.com. 9 | # 10 | 11 | from threading import Thread 12 | import kritter 13 | from dash_devices.dependencies import Output 14 | import dash_bootstrap_components as dbc 15 | import dash_html_components as html 16 | from vizy import Vizy, Perspective 17 | from kritter.ktextvisor import KtextVisor, KtextVisorTable, Image 18 | 19 | 20 | FOCAL_LENGTH = 2260 # measured in pixels, needed for perspective change 21 | FRAMERATE_FOCUS_THRESHOLD = 4 22 | 23 | class Video: 24 | def __init__(self): 25 | # Create and start camera. 26 | self.camera = kritter.Camera(hflip=True, vflip=True) 27 | self.stream = self.camera.stream() 28 | 29 | # Create Kritter server. 30 | kapp = Vizy() 31 | style = {"label_width": 3, "control_width": 6} 32 | 33 | # Create video component and histogram enable. 34 | self.video = kritter.Kvideo(width=self.camera.resolution[0], overlay=True) 35 | hist_enable = kritter.Kcheckbox(name='Histogram', value=False, style=style) 36 | 37 | # Create perspective control and set video modes. 38 | self.perspective = Perspective(self.video, FOCAL_LENGTH, self.camera.getmodes()[self.camera.mode], style=style) 39 | self.perspective.set_video_info_modes([i for m, i in self.camera.getmodes().items()]) 40 | 41 | # Create remaining controls for mode, brightness, framerate, and white balance. 42 | mode = kritter.Kdropdown(name='Camera mode', options=self.camera.getmodes(), value=self.camera.mode, style=style) 43 | brightness = kritter.Kslider(name="Brightness", value=self.camera.brightness, mxs=(0, 100, 1), format=lambda val: f'{val}%', style=style) 44 | self.shutter = kritter.Kslider(name="Shutter-speed", value=self.camera.shutter_speed, mxs=(.0001, 1/self.camera.framerate, .0001), format=lambda val: f'{val:.4f} s', style=style) 45 | self.framerate = kritter.Kslider(name="Framerate", value=self.camera.framerate, format=lambda val : f'{val:.2f} fps', updatemode='mouseup', style=style) 46 | self.framerate.focused = False # Start out in unfocused mode for framerate 47 | autoshutter = kritter.Kcheckbox(name='Auto-shutter', value=self.camera.autoshutter, style=style) 48 | shutter_cont = dbc.Collapse(self.shutter, id=kapp.new_id(), is_open=not self.camera.autoshutter, style=style) 49 | awb = kritter.Kcheckbox(name='Auto-white-balance', value=self.camera.awb, style=style) 50 | red_gain = kritter.Kslider(name="Red gain", value=self.camera.awb_red, mxs=(0.05, 2.0, 0.01), style=style) 51 | blue_gain = kritter.Kslider(name="Blue gain", value=self.camera.awb_red, mxs=(0.05, 2.0, 0.01), style=style) 52 | awb_gains = dbc.Collapse([red_gain, blue_gain], id=kapp.new_id(), is_open=not self.camera.awb) 53 | ir_filter = kritter.Kcheckbox(name='IR filter', value=kapp.power_board.ir_filter(), style=style) 54 | ir_light = kritter.Kcheckbox(name='IR light', value=kapp.power_board.vcc12(), style=style) 55 | 56 | controls = html.Div([hist_enable, self.perspective.layout, mode, brightness, self.framerate, autoshutter, shutter_cont, awb, awb_gains, ir_filter, ir_light]) 57 | 58 | # Add video component and controls to layout. 59 | kapp.layout = html.Div([self.video, controls], style={"padding": "15px"}) 60 | kapp.push_mods(self.handle_framerate()) 61 | 62 | @hist_enable.callback() 63 | def func(value): 64 | return self.video.out_hist_enable(value) 65 | 66 | @brightness.callback() 67 | def func(value): 68 | self.camera.brightness = value 69 | 70 | @self.framerate.callback() 71 | def func(value): 72 | self.camera.framerate = value 73 | return self.handle_framerate() 74 | 75 | @mode.callback() 76 | def func(value): 77 | self.camera.mode = value 78 | return self.video.out_width(self.camera.resolution[0]) + self.framerate.out_value(self.camera.framerate) + self.framerate.out_min(self.camera.min_framerate) + self.framerate.out_max(self.camera.max_framerate) 79 | 80 | @autoshutter.callback() 81 | def func(value): 82 | self.camera.autoshutter = value 83 | return Output(shutter_cont.id, 'is_open', not value) 84 | 85 | @self.shutter.callback() 86 | def func(value): 87 | self.camera.shutter_speed = value 88 | 89 | @awb.callback() 90 | def func(value): 91 | self.camera.awb = value 92 | return Output(awb_gains.id, 'is_open', not value) 93 | 94 | @red_gain.callback() 95 | def func(value): 96 | self.camera.awb_red = value 97 | 98 | @blue_gain.callback() 99 | def func(value): 100 | self.camera.awb_blue = value 101 | 102 | @ir_filter.callback() 103 | def func(value): 104 | kapp.power_board.ir_filter(value) 105 | 106 | @ir_light.callback() 107 | def func(value): 108 | kapp.power_board.vcc12(value) 109 | 110 | @self.video.callback_click() 111 | def func(val): 112 | print(val) 113 | 114 | # Invoke KtextVisor client, which relies on the server running. 115 | # In case it isn't running, we just roll with it. 116 | try: 117 | tv = KtextVisor() 118 | def grab(words, sender, context): 119 | frame = self.frame # copy frame 120 | return Image(frame) 121 | 122 | tv_table = KtextVisorTable({"grab": (grab, "Grabs frame and displays it.")}) 123 | @tv.callback_receive() 124 | def func(words, sender, context): 125 | return tv_table.lookup(words, sender, context) 126 | except: 127 | tv = None 128 | 129 | # Run camera grab thread. 130 | self.run_grab = True 131 | Thread(target=self.grab).start() 132 | 133 | # Run Kritter server, which blocks. 134 | kapp.run() 135 | self.run_grab = False 136 | if tv: 137 | tv.close() 138 | 139 | # This logic deals with the framerate slider, and its transition from "focused" state, 140 | # where its range is reduced to min_framerate--to--FRAMERATE_FOCUS_THRESHOLD+0.5 so 141 | # you can more easily adjust the framerate at those low values -- to "not focused" state where the 142 | # range is normal (min_framerate--to--max_framerate). It also updates the shutter min/max values 143 | # to 144 | def handle_framerate(self): 145 | mods = self.framerate.out_min(self.camera.min_framerate) + self.framerate.out_step(.001) 146 | if self.camera.min_framerateFRAMERATE_FOCUS_THRESHOLD: 150 | mods += self.framerate.out_max(self.camera.max_framerate) 151 | self.framerate.focused = False 152 | else: 153 | if self.camera.framerate<=FRAMERATE_FOCUS_THRESHOLD: 154 | mods += self.framerate.out_max(FRAMERATE_FOCUS_THRESHOLD+0.5) 155 | self.framerate.focused = True 156 | else: 157 | mods += self.framerate.out_max(self.camera.max_framerate) 158 | else: 159 | mods += self.framerate.out_max(self.camera.max_framerate) + self.framerate.out_marks({}) 160 | # Change shutter slider ranges based on the framerate 161 | return mods + self.shutter.out_max(1/self.camera.framerate) + self.shutter.out_value(self.camera.shutter_speed) 162 | 163 | # Frame grabbing thread 164 | def grab(self): 165 | while self.run_grab: 166 | # Get frame 167 | frame = self.stream.frame() 168 | self.frame = self.perspective.transform(frame[0]) 169 | # Send frame 170 | self.video.push_frame(self.frame) 171 | 172 | 173 | if __name__ == "__main__": 174 | Video() 175 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit on error 4 | set -e 5 | 6 | VERSION=0.0.0 7 | 8 | if [[ $EUID -ne 0 ]]; then 9 | sudo bash `realpath "$0"` 10 | exit 11 | fi 12 | 13 | RED='\033[0;31m' 14 | GREEN='\033[0;32m' 15 | YELLOW='\033[0;33m' 16 | NC='\033[0m' 17 | REBOOT=false 18 | SYSD_LOC=/usr/lib/systemd/system 19 | ENV_FILE=/etc/environment 20 | REBOOT=false 21 | 22 | limits_conf() { 23 | local LINE1='pi hard rtprio 99' 24 | local LINE2='pi soft rtprio 99' 25 | local FILE='/etc/security/limits.conf' 26 | grep -qF -- "$LINE1" $FILE || { echo "$LINE1" >> $FILE; REBOOT=true; } 27 | grep -qF -- "$LINE2" $FILE || { echo "$LINE2" >> $FILE; REBOOT=true; } 28 | } 29 | 30 | echo -e "${GREEN}Installing Vizy version ${VERSION}...${NC}" 31 | 32 | if [[ -z "${VIZY_HOME}" ]]; then 33 | eval `cat /etc/environment` 34 | if [[ -z "${VIZY_HOME}" ]]; then 35 | PREV_INSTALL=false 36 | REBOOT=true 37 | DEFAULT_HOME="${HOME}/vizy" 38 | echo -en "${YELLOW}Where would you like to install Vizy? (Press ENTER to choose ${DEFAULT_HOME}):${NC}" 39 | read VIZY_HOME 40 | VIZY_HOME=${VIZY_HOME:-"${DEFAULT_HOME}"} 41 | # Clean ENV_FILE of any previous lines 42 | sed -i '/^VIZY_HOME/d' "${ENV_FILE}" 43 | echo "VIZY_HOME=${VIZY_HOME}" >> "${ENV_FILE}" 44 | DEST_DIR="${VIZY_HOME}" 45 | fi 46 | fi 47 | if [[ -n "${VIZY_HOME}" ]]; then 48 | PREV_INSTALL=true 49 | DEST_DIR="${VIZY_HOME}.new" 50 | fi 51 | 52 | # Update firmware if necessary 53 | echo -e "\n${GREEN}Checking power firmware...${NC}\n" 54 | scripts/update_power_firmware 55 | # Check/install services 56 | if ! scripts/install_services; then 57 | REBOOT=true 58 | fi 59 | # Change limits.conf file if necessary 60 | limits_conf 61 | 62 | # Install system packages 63 | echo -e "\n${GREEN}Installing system packages...${NC}\n" 64 | apt-get -y install libportaudio2 65 | apt-get -y install zip unzip 66 | 67 | # Upgrade pip 68 | echo -e "\n${GREEN}Upgrading pip...${NC}\n" 69 | python3 -m pip install --upgrade pip 70 | 71 | # Install this pre-compiled version of numpy before we install tensorflow 72 | echo -e "\n${GREEN}Installing numpy 1.21.6...${NC}\n" 73 | python3 -m pip install numpy-1.21.6-cp37-cp37m-linux_armv7l.whl --root-user-action=ignore --no-warn-conflicts 74 | 75 | # Install any packages that aren't included in the original image 76 | # Note, we can install specific versions of packages, but these packages will install the dependencies and the 77 | # dependencies' versions may change when new versions come out and introduce errors. This happened with tflite and protobuf. 78 | echo -e "\n${GREEN}Installing protobuf 3.20.0...${NC}\n" 79 | python3 -m pip install protobuf==3.20.0 --root-user-action=ignore --no-warn-conflicts 80 | echo -e "\n${GREEN}Installing aiohttp 3.8.1...${NC}\n" 81 | python3 -m pip install aiohttp==3.8.1 --root-user-action=ignore --no-warn-conflicts 82 | echo -e "\n${GREEN}Installing numexpr 2.7.0...${NC}\n" 83 | python3 -m pip install numexpr==2.7.0 --root-user-action=ignore --no-warn-conflicts 84 | echo -e "\n${GREEN}Installing gspread-dataframe 3.3.0...${NC}\n" 85 | python3 -m pip install gspread-dataframe==3.3.0 --root-user-action=ignore --no-warn-conflicts 86 | echo -e "\n${GREEN}Installing cachetools 5.2.0...${NC}\n" 87 | python3 -m pip install cachetools==5.2.0 --root-user-action=ignore --no-warn-conflicts 88 | echo -e "\n${GREEN}Installing google-auth 2.12.0...${NC}\n" 89 | python3 -m pip install google-auth==2.12.0 --root-user-action=ignore --no-warn-conflicts 90 | echo -e "\n${GREEN}Installing python-telegram-bot 20.0.a4...${NC}\n" 91 | python3 -m pip install python-telegram-bot==20.0.a4 --root-user-action=ignore --no-warn-conflicts 92 | echo -e "\n${GREEN}Installing opencv-python 4.5.3.56...${NC}\n" 93 | python3 -m pip install opencv-python==4.5.3.56 --root-user-action=ignore --no-warn-conflicts 94 | echo -e "\n${GREEN}Installing tflite-runtime 2.7.0...${NC}\n" 95 | python3 -m pip install tflite-runtime==2.7.0 --root-user-action=ignore --no-warn-conflicts 96 | echo -e "\n${GREEN}Installing tflite-support 0.4.0...${NC}\n" 97 | python3 -m pip install tflite-support==0.4.0 --root-user-action=ignore --no-warn-conflicts 98 | echo -e "\n${GREEN}Installing openpyxl 3.0.10...${NC}\n" 99 | python3 -m pip install openpyxl==3.0.10 --root-user-action=ignore --no-warn-conflicts 100 | echo -e "\n${GREEN}Installing gdown 4.6.0...${NC}\n" 101 | python3 -m pip install gdown==4.6.0 --root-user-action=ignore --no-warn-conflicts 102 | 103 | # Install any wheels if included. Do this AFTER we install the packages because if any 104 | # of the packages fail to install, we don't want a new version of Kritter (which is installed 105 | # as a wheel) to reference non-existent packages. 106 | WHLS="*.whl" 107 | echo "${PWD}" 108 | for f in ${WHLS}; do 109 | # exclude numpy since we've already installed it 110 | if [ ${f} != "numpy-1.21.6-cp37-cp37m-linux_armv7l.whl" ] 111 | then 112 | echo -e "\n${GREEN}Installing ${f}...${NC}\n" 113 | python3 -m pip install --force-reinstall ${f} --root-user-action=ignore --no-warn-conflicts 114 | fi 115 | done 116 | 117 | # Update dash_renderer version so browsers load the new version 118 | DR_INIT_FILE="/usr/local/lib/python3.7/dist-packages/dash_renderer/__init__.py" 119 | DR_NEW_VER="1.9.2" 120 | DR_OLD_VER=`grep -oP '(?<=version__ = ").*?(?=")' <<< "$s" ${DR_INIT_FILE}` 121 | sed -i 's/"'${DR_OLD_VER}'"/"'${DR_NEW_VER}'"/g' ${DR_INIT_FILE} 122 | 123 | # Uninstall vizy 124 | echo -e "\n${GREEN}Uninstalling previous Vizy version...${NC}\n" 125 | python3 -m pip uninstall -y vizy --root-user-action=ignore 126 | # Install vizy 127 | echo -e "\n${GREEN}Installing Vizy...${NC}\n" 128 | python3 setup.py install --force 129 | # Copy to final destination 130 | echo -e "\n${GREEN}Copying...${NC}" 131 | mkdir -p "${DEST_DIR}" 132 | if [ -d apps ]; then 133 | cp -r apps "${DEST_DIR}" 134 | fi 135 | if [ -d examples ]; then 136 | cp -r examples "${DEST_DIR}" 137 | fi 138 | if [ -d scripts ]; then 139 | cp -r scripts "${DEST_DIR}" 140 | fi 141 | if [ -d sys ]; then 142 | cp -r sys "${DEST_DIR}" 143 | fi 144 | 145 | if ${PREV_INSTALL}; then 146 | # Move settings and projects in etc directory 147 | mv "${VIZY_HOME}/etc" "${DEST_DIR}" 148 | # Remove previous backup (we only keep one) 149 | rm -rf "${VIZY_HOME}.bak" 150 | # Rename direcories 151 | mv "${VIZY_HOME}" "${VIZY_HOME}.bak" 152 | mv "${DEST_DIR}" "${VIZY_HOME}" 153 | fi 154 | 155 | # Change ownership to pi 156 | chown -R pi "${VIZY_HOME}" 157 | 158 | if ${REBOOT}; then 159 | echo -en "\n${YELLOW}Reboot required. Would you like to reboot now? (y or n):${NC}" 160 | read -n 1 -r 161 | echo 162 | if [[ $REPLY =~ ^[Yy]$ ]]; then 163 | reboot now 164 | fi 165 | elif ${PREV_INSTALL}; then 166 | # Restart vizy software 167 | # If we're installing via Vizyvisor, this will kill ourselves before 168 | # we print a reassuring "success" message (which is important), so we 169 | # have an option to skip/defer. 170 | if [[ -z "${VIZY_NO_RESTART}" ]]; then 171 | echo -e "\n${GREEN}Restarting...${NC}" 172 | service vizy-power-monitor restart 173 | service vizy-server restart 174 | fi 175 | fi 176 | -------------------------------------------------------------------------------- /scripts/install_services: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [[ $EUID -ne 0 ]]; then 4 | sudo bash `realpath "$0"` 5 | exit $? 6 | fi 7 | 8 | THISDIR="${0%/*}" 9 | DIFFER=0 10 | 11 | echo Installing Vizy services... 12 | 13 | # Check each file for changes, set DIFFER accordingly. 14 | if ! cmp -s "$THISDIR/../sys/vizy-server.service" /usr/lib/systemd/system/vizy-server.service; then 15 | cp "$THISDIR/../sys/vizy-server.service" /usr/lib/systemd/system 16 | systemctl enable vizy-server 17 | DIFFER=1 18 | fi 19 | if ! cmp -s "$THISDIR/../sys/vizy-power-monitor.service" /usr/lib/systemd/system/vizy-power-monitor.service; then 20 | cp "$THISDIR/../sys/vizy-power-monitor.service" /usr/lib/systemd/system 21 | systemctl enable vizy-power-monitor 22 | DIFFER=1 23 | fi 24 | if ! cmp -s "$THISDIR/../sys/vizy-power-off.service" /usr/lib/systemd/system/vizy-power-off.service; then 25 | cp "$THISDIR/../sys/vizy-power-off.service" /usr/lib/systemd/system 26 | systemctl enable vizy-power-off 27 | DIFFER=1 28 | fi 29 | 30 | echo Done 31 | exit $DIFFER 32 | -------------------------------------------------------------------------------- /scripts/install_update: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import os 4 | from termcolor import colored 5 | # Print init message ASAP. 6 | print(colored(f"This handy-dandy script ({os.path.realpath(__file__)}) will check vizycam.com for updates and/or install them for you. Run with -h to get help.\n", "green")) 7 | 8 | import time 9 | from vizy.updatedialog import get_latest 10 | from vizy import __version__, dirs, VizyConfig 11 | import argparse 12 | 13 | TRIES = 3 14 | 15 | parser = argparse.ArgumentParser() 16 | parser.add_argument('file_url', nargs="?", help="package file or package URL") 17 | args = parser.parse_args() 18 | 19 | try: 20 | file_url = None 21 | homedir, etcdir = dirs(2) 22 | install_dir = os.path.join(homedir, "tmp") 23 | config = VizyConfig(etcdir) 24 | install_file = None 25 | if args.file_url is None: 26 | print(colored("Hold on for a sec while we check for updates and download them...\n", "green")) 27 | latest, newer = get_latest(config.config, TRIES) 28 | if newer: 29 | file_url = os.path.join(latest['server'], latest['install_file']) 30 | install_file = latest['install_file'] 31 | print(colored(f"Version {latest['version']} found! Downloading...\n", "green")) 32 | # Make install directory 33 | try: 34 | os.mkdir(install_dir) 35 | except FileExistsError: 36 | pass 37 | else: 38 | print(colored(f"You are running version {__version__}. This is the latest version!", "green")) 39 | exit() 40 | 41 | # URL is provided 42 | elif args.file_url.lower().startswith("http"): 43 | file_url = args.file_url 44 | install_file = os.path.basename(file_url) 45 | print(colored("Downloading...\n", "green")) 46 | # Make install directory 47 | try: 48 | os.mkdir(install_dir) 49 | except FileExistsError: 50 | pass 51 | # Filename is provided 52 | else: 53 | install_dir = os.path.dirname(args.file_url) 54 | install_file = os.path.basename(args.file_url) 55 | print(colored(f"Installing {install_file}...\n", "green")) 56 | 57 | 58 | if file_url: 59 | # Download file to temp directory 60 | for i in range(TRIES): 61 | res = os.system(f"cd {install_dir}; wget {file_url}") 62 | if res==0: 63 | break 64 | print(f"Trying again (attempt {str(i+1)})...") 65 | if res!=0: 66 | raise RuntimeError(f"unable to download file {file_url}.") 67 | # Run installer. We need to use bash because it probably comes across without 68 | # the execute permission set. 69 | print(colored("Download successful! Installing...\n", "green")) 70 | 71 | if not os.path.exists(os.path.join(install_dir, install_file)): 72 | raise RuntimeError("Unable to find install file " + install_file + ".") 73 | 74 | if os.system(f"export VIZY_NO_RESTART=1; cd {install_dir}; bash {install_file}")!=0: 75 | raise RuntimeError("Installation has encountered an error.") 76 | 77 | except Exception as e: 78 | print(colored(f"Error: {e}", "red")) 79 | exit() 80 | 81 | print(colored("\nSuccess!", "green")) 82 | 83 | print(colored("Please wait several seconds while Vizy software restarts...", "green")) 84 | # Wait for message to be seen... 85 | time.sleep(5) 86 | # Run restart in background in case Vizy server is running this script, e.g. for software update. 87 | os.system("(service vizy-power-monitor restart; service vizy-server restart) &") 88 | -------------------------------------------------------------------------------- /scripts/update_power_firmware: -------------------------------------------------------------------------------- 1 | #!/bin/python3 2 | import os 3 | from vizy import VizyPowerBoard 4 | 5 | vpb = VizyPowerBoard() 6 | 7 | installed_ver = vpb.fw_version() 8 | if installed_ver==[0, 0, 0]: 9 | print("No power board detected.") 10 | exit(1) 11 | 12 | THIS_DIR = os.path.dirname(os.path.realpath(__file__)) 13 | VIZY_DIR = os.path.normpath(os.path.join(THIS_DIR, "..")) 14 | 15 | files = os.listdir(os.path.join(VIZY_DIR, "sys")) 16 | files.sort(key=lambda n: n.lower(), reverse=True) 17 | firmware = [f for f in files if f.endswith(".fwe")][0] 18 | 19 | s = firmware.split(".") 20 | file_ver = [int(s[0].split("-")[-1]), int(s[-3]), int(s[-2])] 21 | file_ver_str = '.'.join([str(i) for i in file_ver]) 22 | installed_ver_str = '.'.join([str(i) for i in installed_ver]) 23 | print(f"Current power board firmware is version {installed_ver_str}.") 24 | 25 | vizy_fu = os.path.join(VIZY_DIR, "scripts/vizy_fu") 26 | 27 | fvn = (file_ver[0]<<16) + (file_ver[1]<<8) + (file_ver[2]<<0) 28 | ivn = (installed_ver[0]<<16) + (installed_ver[1]<<8) + (installed_ver[2]<<0) 29 | if fvn>ivn: 30 | print(f"Found more recent firmware! Installing version {file_ver_str}...") 31 | os.system(f"{vizy_fu} {os.path.join(VIZY_DIR, 'sys', firmware)}") 32 | else: 33 | print("Power board firmware is up to date.") 34 | -------------------------------------------------------------------------------- /scripts/vizy_fu: -------------------------------------------------------------------------------- 1 | #!/bin/python3 2 | import os, sys 3 | if os.geteuid()!=0: 4 | sys.exit("You need to run vizy_fu with root permissions: sudo ./vizy_fu ...") 5 | 6 | import argparse 7 | import smbus 8 | import time 9 | from vizy import VizyPowerBoard 10 | 11 | VERSION = '1.0' 12 | 13 | def send_frame(port, frame): 14 | port.write(frame) 15 | reply = port.read() 16 | 17 | if reply and reply in b'@ABC': 18 | return ord(reply) 19 | else: 20 | return ord(b'?') 21 | 22 | def check_version(port, hw_version, fw_version): 23 | for i in range(2): 24 | oper = fw_version[i*2] 25 | ver = fw_version[i*2+1] 26 | 27 | if oper==0: 28 | if ver.") 159 | 160 | -------------------------------------------------------------------------------- /scripts/vizy_power_monitor: -------------------------------------------------------------------------------- 1 | #!/bin/python3 2 | import time 3 | import os 4 | import signal 5 | from datetime import datetime 6 | from vizy import VizyPowerBoard, get_cpu_temp 7 | 8 | BRIGHTNESS = 0x30 9 | WHITE = [BRIGHTNESS//3, BRIGHTNESS//3, BRIGHTNESS//3] 10 | YELLOW = [BRIGHTNESS//2, BRIGHTNESS//2, 0] 11 | SYNC_TIMEOUT = 60*5 # seconds 12 | # Start using fan at this temperature. 13 | TEMP_MIN = 65 # Celcius 14 | # CPU is throttled back at 80C -- we want to try our hardest not to get there. 15 | TEMP_MAX = 75 16 | FAN_MIN = 1 17 | FAN_MAX = 4 18 | FAN_WINDOW = 30 # seconds 19 | FAN_ATTEN = 0.25 20 | 21 | class PowerMonitor: 22 | 23 | def __init__(self): 24 | print("Running Vizy Power Monitor...") 25 | self.count = 0 26 | self.fan_speed = (0, 0) 27 | self.avg_fan_speed = 0 28 | self.run = True 29 | 30 | def handler(signum, frame): 31 | self.run = False 32 | 33 | signal.signal(signal.SIGINT, handler) 34 | signal.signal(signal.SIGTERM, handler) 35 | 36 | self.v = VizyPowerBoard() 37 | 38 | # Set time using battery-backed RTC time on Vizy Power Board, 39 | # unless it's already been set by systemd-timesyncd. 40 | # So we set the time based on the RTC value. If we can't sync 41 | # in the future because we don't have a network connection, 42 | # we have the battery-backed RTC value to fall back on. 43 | if not os.path.exists("/run/systemd/timesync/synchronized"): 44 | self.v.rtc_set_system_datetime() 45 | 46 | # Set background LED to yellow (finished booting). 47 | self.v.led_background(*YELLOW) 48 | 49 | # Poll continuously... 50 | while self.run: 51 | 52 | self.handle_timesync() 53 | self.handle_fan() 54 | self.handle_power_button() 55 | 56 | time.sleep(1) 57 | 58 | if self.v.led_background()==YELLOW: 59 | self.v.led_background(*WHITE) 60 | self.v.fan(0) 61 | 62 | print("Exiting Vizy Power Monitor") 63 | 64 | 65 | def handle_power_button(self): 66 | # Check power button status. 67 | powerOff = self.v.power_off_requested() 68 | if powerOff: 69 | # Initate shutdown. 70 | # Turn off background LED. 71 | self.v.led_background(0, 0, 0) 72 | # Flash LED red as we shut down. 73 | self.v.led(255, 0, 0, 0, True, 15, 500, 500) 74 | os.system('shutdown now') 75 | self.run = False 76 | 77 | 78 | def handle_timesync(self): 79 | # Spend the first minutes looking for timesync update so we can update the RTC. 80 | if self.countFAN_MAX: 98 | fan_speed = FAN_MAX 99 | else: 100 | fan_speed = round(self.avg_fan_speed) 101 | 102 | #print(temp, fan_speed, self.avg_fan_speed) 103 | t = time.time() 104 | 105 | # Be more responsive to increases in fan speed than decreases. 106 | if fan_speed>self.fan_speed[0]: 107 | self.fan_speed = (fan_speed, t) 108 | self.v.fan(fan_speed) 109 | # Only decrease fan speed if our window expires. 110 | elif t-self.fan_speed[1]>FAN_WINDOW: 111 | self.fan_speed = (fan_speed, t) 112 | self.v.fan(fan_speed) 113 | 114 | 115 | if __name__ == "__main__": 116 | 117 | PowerMonitor() 118 | 119 | 120 | 121 | -------------------------------------------------------------------------------- /scripts/vizy_power_off: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | echo 'Powering off Vizy' 3 | # Turn off Vizy via power board, specify 10 seconds (100 deciseconds) from now 4 | # Note, we do with through i2cset because running Python and loading 5 | # VizyPowerBoard can take a few seconds. 6 | i2cset -y 1 0x14 38 0x1f 100 i 7 | -------------------------------------------------------------------------------- /scripts/vizy_server: -------------------------------------------------------------------------------- 1 | #!/bin/python3 2 | from vizy import VizyVisor 3 | 4 | vv = VizyVisor() 5 | vv.run() 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Vizy 3 | # 4 | # All Vizy source code is provided under the terms of the 5 | # GNU General Public License v2 (http://www.gnu.org/licenses/gpl-2.0.html). 6 | # Those wishing to use Vizy source code, software and/or 7 | # technologies under different licensing terms should contact us at 8 | # support@charmedlabs.com. 9 | # 10 | 11 | from setuptools import setup 12 | import os 13 | 14 | 15 | about = {} 16 | with open(os.path.join("src/vizy", "about.py"), encoding="utf-8") as fp: 17 | exec(fp.read(), about) 18 | 19 | #depencencies 20 | #kritter 21 | #wiringpi 22 | 23 | setup( 24 | name=about['__title__'], 25 | version=about['__version__'], 26 | author=about['__author__'], 27 | author_email=about['__email__'], 28 | license=about['__license__'], 29 | package_dir={"": "src"}, 30 | packages=["vizy"], 31 | package_data = {"": ['*.jpg'], "vizy": ["media/*", "login/*"]}, 32 | zip_safe=False 33 | ) -------------------------------------------------------------------------------- /src/vizy/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Vizy 3 | # 4 | # All Vizy source code is provided under the terms of the 5 | # GNU General Public License v2 (http://www.gnu.org/licenses/gpl-2.0.html). 6 | # Those wishing to use Vizy source code, software and/or 7 | # technologies under different licensing terms should contact us at 8 | # support@charmedlabs.com. 9 | # 10 | 11 | from .about import __version__ 12 | from .vizy import Vizy, dirs, VizyConfig, BASE_DIR, ETCDIR_NAME, APPSDIR_NAME, EXAMPLESDIR_NAME, SCRIPTSDIR_NAME 13 | from .vizypowerboard import VizyPowerBoard, get_cpu_temp 14 | from .vizyvisor import VizyVisor 15 | from .perspective import Perspective 16 | from .mediadisplayqueue import MediaDisplayQueue 17 | from .newprojectdialog import NewProjectDialog 18 | from .openprojectdialog import OpenProjectDialog 19 | from .exportprojectdialog import ExportProjectDialog 20 | from .importprojectdialog import ImportProjectDialog 21 | -------------------------------------------------------------------------------- /src/vizy/about.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Vizy 3 | # 4 | # All Vizy source code is provided under the terms of the 5 | # GNU General Public License v2 (http://www.gnu.org/licenses/gpl-2.0.html). 6 | # Those wishing to use Vizy source code, software and/or 7 | # technologies under different licensing terms should contact us at 8 | # support@charmedlabs.com. 9 | # 10 | 11 | __title__ = "vizy" 12 | __version__ = "0.2.138" 13 | __license__ = "GPL2" 14 | __author__ = "Charmed Labs LLC" 15 | __email__ = "support@charmedlabs.com" 16 | -------------------------------------------------------------------------------- /src/vizy/aboutdialog.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Vizy 3 | # 4 | # All Vizy source code is provided under the terms of the 5 | # GNU General Public License v2 (http://www.gnu.org/licenses/gpl-2.0.html). 6 | # Those wishing to use Vizy source code, software and/or 7 | # technologies under different licensing terms should contact us at 8 | # support@charmedlabs.com. 9 | # 10 | 11 | import os 12 | import dash_html_components as html 13 | from kritter import Kritter, Ktext, Kbutton, Kdialog, KsideMenuItem 14 | from dash_devices.dependencies import Output 15 | 16 | class AboutDialog: 17 | 18 | def __init__(self, kapp, pmask, pmask_edit): 19 | self.kapp = kapp 20 | # pmask isn't used because everyone can view about dialog. 21 | 22 | style = {"label_width": 3, "control_width": 8} 23 | self.img = html.Img(id=self.kapp.new_id(), style={"display": "block", "max-width": "100%", "margin-left": "auto", "margin-right": "auto"}) 24 | self.version = Ktext(name="Version", style=style) 25 | self.loc = Ktext(name="Location", style=style) 26 | self.author = Ktext(name="Author", style=style) 27 | self.desc = Ktext(name="Description", style=style) 28 | self.info_button = Kbutton(name=[Kritter.icon("info-circle"), "More info"], target="_blank") 29 | self.view_edit_button = Kbutton(name=[Kritter.icon("edit"), "View/edit"], external_link=True, target="_blank") 30 | self.info_button.append(self.view_edit_button) 31 | layout = [self.img, self.version, self.author, self.loc, self.desc] 32 | self.dialog = Kdialog(title="", layout=layout, left_footer=self.info_button) 33 | self.layout = KsideMenuItem("", self.dialog, "info-circle") 34 | 35 | @self.kapp.callback_connect 36 | def func(client, connect): 37 | if connect: 38 | # Being able to view/edit source is privileged. 39 | return self.view_edit_button.out_disp(client.authentication&pmask_edit) 40 | 41 | def out_update(self, prog): 42 | mods = [] 43 | title = f"About {prog['name']}" 44 | if prog['version']: 45 | version = f"{prog['version']}, installed or modified on {prog['mrfd']}" 46 | else: 47 | version = f"installed or modified on {prog['mrfd']}" 48 | 49 | email = html.A(prog['email'], href=f"mailto:{prog['email']}") if prog["email"] else None 50 | if prog["author"]: 51 | if email: 52 | author = [prog["author"] + ", ", email] 53 | else: 54 | author = prog["author"] 55 | elif email: 56 | author = email 57 | else: 58 | author = None 59 | 60 | mods += self.loc.out_value(os.path.join(self.kapp.homedir, prog['path'])) 61 | mods += self.info_button.out_disp(bool(prog['url'])) 62 | if prog['url']: 63 | mods += self.info_button.out_url(prog['url']) 64 | 65 | mods += self.author.out_disp(bool(author)) 66 | if author: 67 | mods += self.author.out_value(author) 68 | 69 | mods += self.desc.out_disp(bool(prog['description'])) 70 | if prog['description']: 71 | mods += self.desc.out_value(prog['description']) 72 | 73 | return mods + self.layout.out_name(title) + self.dialog.out_title([Kritter.icon("info-circle"), title]) + [Output(self.img.id, "src", prog['image_no_bg'])] + self.version.out_value(version) -------------------------------------------------------------------------------- /src/vizy/exportprojectdialog.py: -------------------------------------------------------------------------------- 1 | import os 2 | import kritter 3 | import base64 4 | import json 5 | import dash_core_components as dcc 6 | import dash_html_components as html 7 | from dash_devices.dependencies import Input, Output, State 8 | 9 | class ExportProjectDialog(kritter.Kdialog): 10 | 11 | def __init__(self, gdrive, key_type, file_info_func, key_func=None): 12 | self.gdrive = gdrive 13 | self.key_type = key_type 14 | self.file_info_func = file_info_func 15 | self.key_func = key_func 16 | self.export = kritter.Kbutton(name=[kritter.Kritter.icon("cloud-upload"), "Export"], spinner=True) 17 | self.status = kritter.Ktext(style={"control_width": 12}) 18 | self.copy_key = kritter.Kbutton(name=[kritter.Kritter.icon("copy"), "Copy share key"], disp=False) 19 | self.key_store = dcc.Store(data="hello_there", id=kritter.Kritter.new_id()) 20 | super().__init__(title=[kritter.Kritter.icon("cloud-upload"), "Export project"], layout=[self.export, self.status, self.copy_key, self.key_store], shared=True) 21 | 22 | # This code copies to the clipboard using the hacky method. 23 | # (You need a secure page (https) to perform navigator.clipboard operations.) 24 | script = """ 25 | function(click, url) { 26 | var textArea = document.createElement("textarea"); 27 | textArea.value = url; 28 | textArea.style.position = "fixed"; 29 | document.body.appendChild(textArea); 30 | textArea.focus(); 31 | textArea.select(); 32 | document.execCommand('copy'); 33 | textArea.remove(); 34 | } 35 | """ 36 | self.kapp.clientside_callback(script, Output("_none", kritter.Kritter.new_id()), [Input(self.copy_key.id, "n_clicks")], state=[State(self.key_store.id, "data")]) 37 | 38 | def _update_status(percent): 39 | self.kapp.push_mods(self.status.out_value(f"Copying to Google Drive ({percent}%)...")) 40 | 41 | @self.callback_view() 42 | def func(state): 43 | if not state: 44 | return self.status.out_value("") + self.copy_key.out_disp(False) 45 | 46 | @self.export.callback() 47 | def func(): 48 | self.kapp.push_mods(self.export.out_spinner_disp(True) + self.status.out_value("Zipping project files...") + self.copy_key.out_disp(False)) 49 | file_info = self.file_info_func() 50 | os.chdir(file_info['project_dir']) 51 | files_string = '' 52 | for i in file_info['files']: 53 | files_string += f" '{i}'" 54 | files_string = files_string[1:] 55 | export_file = kritter.time_stamped_file("zip", f"{file_info['project_name']}_export_") 56 | os.system(f"zip -r '{export_file}' {files_string}") 57 | gdrive_file = os.path.join(file_info['gdrive_dir'], export_file) 58 | try: 59 | self.gdrive.copy_to(os.path.join(file_info['project_dir'], export_file), gdrive_file, True, _update_status) 60 | except Exception as e: 61 | print("Unable to upload project export file to Google Drive.", e) 62 | self.kapp.push_mods(self.status.out_value(f'Unable to upload project export file to Google Drive. ({e})')) 63 | return 64 | url = self.gdrive.get_url(gdrive_file) 65 | pieces = url.split("/") 66 | # Remove obvous non-id pieces 67 | pieces = [i for i in pieces if i.find(".")<0 and i.find("?")<0] 68 | # sort by size 69 | pieces.sort(key=len, reverse=True) 70 | # The biggest piece is going to be the id. Encode with the project name, surround by V's to 71 | # prevent copy-paste errors (the key might be emailed, etc.) 72 | key = f"V{base64.b64encode(json.dumps([self.key_type, file_info['project_name'], pieces[0]]).encode()).decode()}V" 73 | # Write key to file for safe keeping 74 | key_filename = os.path.join(file_info['project_dir'], kritter.time_stamped_file("key", "share_key_")) 75 | with open(key_filename, "w") as file: 76 | file.write(key) 77 | if self.key_func: 78 | self.key_func(key) 79 | return self.status.out_value(["Done! Press ", html.B("Copy share key"), " button to copy to clipboard."]) + self.copy_key.out_disp(True) + self.export.out_spinner_disp(False) + [Output(self.key_store.id, "data", key)] 80 | -------------------------------------------------------------------------------- /src/vizy/ifttt.py: -------------------------------------------------------------------------------- 1 | # imports 2 | import requests 3 | from json import dumps 4 | 5 | 6 | class IFTTT_Wrapper: 7 | def __init__(self, key): 8 | self.key = key if key else '' 9 | self.url_base = 'https://maker.ifttt.com/trigger/' 10 | 11 | def ping_event(self, event_name, event_type, data): 12 | if self.key: 13 | url = self.build_url(event_name, event_type, data) 14 | try: 15 | r = requests.post(url, data) 16 | except Exception as e: 17 | print ('Failed: ', e) 18 | else: 19 | print('no key') 20 | 21 | def build_url(self, event_name, event_type, data): 22 | url = f"{self.url_base}{event_name}/" 23 | if event_type=='json': # elif event_type=='parameter': 24 | url += 'json/' 25 | url += f"with/key/{self.key}" 26 | return url 27 | # match event_type: 28 | # case 'json': 29 | # case 'parameter': 30 | # case _: return # default -------------------------------------------------------------------------------- /src/vizy/importprojectdialog.py: -------------------------------------------------------------------------------- 1 | import os 2 | import kritter 3 | import base64 4 | import json 5 | import time 6 | import gdown 7 | 8 | IMPORT_FILE = "import.zip" 9 | 10 | class ImportProjectDialog(kritter.Kdialog): 11 | 12 | def __init__(self, gdrive, project_dir, key_type): 13 | self.gdrive = gdrive 14 | self.project_dir = project_dir 15 | self.key_type = key_type 16 | self.callback_func = None 17 | self.key_c = kritter.KtextBox(placeholder="Paste share key here") 18 | self.import_button = kritter.Kbutton(name=[kritter.Kritter.icon("cloud-download"), "Import"], spinner=True, disabled=True) 19 | self.key_c.append(self.import_button) 20 | self.status = kritter.Ktext(style={"control_width": 12}) 21 | self.confirm_text = kritter.Ktext(style={"control_width": 12}) 22 | self.confirm_dialog = kritter.KyesNoDialog(title="Confirm", layout=self.confirm_text, shared=True) 23 | super().__init__(title=[kritter.Kritter.icon("cloud-download"), "Import project"], layout=[self.key_c, self.status, self.confirm_dialog], shared=True) 24 | 25 | @self.confirm_dialog.callback_response() 26 | def func(val): 27 | if val: 28 | self.kapp.push_mods(self.import_button.out_spinner_disp(True)) 29 | mods = self.import_button.out_spinner_disp(False) 30 | self.project_name = self._next_project() 31 | self.kapp.push_mods(self.confirm_dialog.out_open(False)) 32 | return mods + self._import() 33 | 34 | @self.callback_view() 35 | def func(state): 36 | if not state: 37 | return self.status.out_value("") + self.key_c.out_value("") + self.import_button.out_disabled(True) 38 | 39 | @self.key_c.callback() 40 | def func(key): 41 | return self.import_button.out_disabled(False) 42 | 43 | @self.import_button.callback(self.key_c.state_value()) 44 | def func(key): 45 | self.kapp.push_mods(self.import_button.out_spinner_disp(True)) 46 | mods = self.import_button.out_spinner_disp(False) 47 | key = key.strip() 48 | if key.startswith('V') and key.endswith('V'): 49 | try: 50 | key = key[1:-1] 51 | data = json.loads(base64.b64decode(key.encode()).decode()) 52 | if data[0]!=self.key_type: 53 | raise RuntimeError("This is not the correct type of key.") 54 | self.project_name = data[1] 55 | self.key = data[2] 56 | # We could add a callback here for client code to verify and raise exception 57 | except Exception as e: 58 | return mods + self.status.out_value(f"This key appears to be invalid. ({e})") 59 | if os.path.exists(os.path.join(self.project_dir, self.project_name)): 60 | return mods + self.confirm_text.out_value(f'A project named "{self.project_name}" already exists. Would you like to save it as "{self._next_project()}"?') + self.confirm_dialog.out_open(True) 61 | return mods + self._import() 62 | else: 63 | return mods + self.status.out_value('Share keys start and end with a "V" character.') 64 | 65 | def _next_project(self): 66 | project_name = self.project_name+"_" 67 | while os.path.exists(os.path.join(self.project_dir, project_name)): 68 | project_name += "_" 69 | return project_name 70 | 71 | def _update_status(self, percent): 72 | self.kapp.push_mods(self.status.out_value(f"Downloading {self.project_name} project ({percent}%)...")) 73 | 74 | def _import(self): 75 | try: 76 | new_project_dir = os.path.join(self.project_dir, self.project_name) 77 | os.makedirs(new_project_dir) 78 | import_file = os.path.join(new_project_dir, IMPORT_FILE) 79 | # Use gdown code to download if we don't have Google Drive credentials 80 | if self.gdrive is None: 81 | self.kapp.push_mods(self.status.out_value(f"Downloading {self.project_name} project...")) 82 | gdown.download(id=self.key, output=import_file) 83 | else: # Otherwise use Google credentials, which gives us a some feedback. 84 | self.gdrive.download(self.key, import_file, self._update_status) 85 | self.kapp.push_mods(self.status.out_value("Unzipping project files...")) 86 | os.chdir(new_project_dir) 87 | os.system(f"unzip {IMPORT_FILE}") 88 | os.remove(import_file) 89 | except Exception as e: 90 | print("Unable to import project.", e) 91 | os.rmdir(new_project_dir) 92 | self.kapp.push_mods(self.status.out_value(f'Unable to import project. ({e})')) 93 | return [] 94 | self.kapp.push_mods(self.status.out_value("Done!")) 95 | time.sleep(1) 96 | mods = self.out_open(False) 97 | if self.callback_func: 98 | res = self.callback_func(self.project_name) 99 | if isinstance(res, list): 100 | mods += res 101 | return mods 102 | 103 | def callback(self): 104 | def wrap_func(func): 105 | self.callback_func = func 106 | return wrap_func 107 | 108 | -------------------------------------------------------------------------------- /src/vizy/login/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Vizy login 6 | 7 | 32 | 33 | 34 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /src/vizy/login/vizy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmedlabs/vizy/59e443cf61150b3b4072c1817665757b1aad611b/src/vizy/login/vizy.png -------------------------------------------------------------------------------- /src/vizy/media/bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmedlabs/vizy/59e443cf61150b3b4072c1817665757b1aad611b/src/vizy/media/bg.jpg -------------------------------------------------------------------------------- /src/vizy/media/default_bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmedlabs/vizy/59e443cf61150b3b4072c1817665757b1aad611b/src/vizy/media/default_bg.jpg -------------------------------------------------------------------------------- /src/vizy/media/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmedlabs/vizy/59e443cf61150b3b4072c1817665757b1aad611b/src/vizy/media/favicon.ico -------------------------------------------------------------------------------- /src/vizy/media/vizy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmedlabs/vizy/59e443cf61150b3b4072c1817665757b1aad611b/src/vizy/media/vizy.png -------------------------------------------------------------------------------- /src/vizy/media/vizy_eye.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmedlabs/vizy/59e443cf61150b3b4072c1817665757b1aad611b/src/vizy/media/vizy_eye.png -------------------------------------------------------------------------------- /src/vizy/mediadisplayqueue.py: -------------------------------------------------------------------------------- 1 | import os 2 | import kritter 3 | import dash_html_components as html 4 | from dash_devices.dependencies import Output 5 | from functools import wraps 6 | 7 | 8 | class MediaDisplayQueue: 9 | def __init__(self, media_dir, display_width, media_width, media_display_width=300, num_media=25, font_size=12, disp=True, kapp=None): 10 | self.display_width = display_width 11 | self.media_width = media_width 12 | self.media_display_width = media_display_width 13 | self.num_media = num_media 14 | self.font_size = font_size 15 | self.kapp = kritter.Kritter.kapp if kapp is None else kapp 16 | self.set_media_dir(media_dir) 17 | self.dialog_image = kritter.Kimage(overlay=True, service=None) 18 | self.image_dialog = kritter.Kdialog(title="", layout=[self.dialog_image], size="xl") 19 | self.dialog_video = kritter.Kvideo(src="") 20 | self.video_dialog = kritter.Kdialog(title="", layout=[self.dialog_video], size="xl") 21 | self.layout = html.Div([html.Div(self._create_images(), id=self.kapp.new_id(), style={"white-space": "nowrap", "max-width": f"{self.display_width}px", "width": "100%", "overflow-x": "auto"}), self.image_dialog, self.video_dialog], id=self.kapp.new_id(), style={"display": "block"} if disp else {"display": "none"}) 22 | 23 | def _create_images(self): 24 | children = [] 25 | self.images = [] 26 | for i in range(self.num_media): 27 | kimage = kritter.Kimage(width=self.media_display_width, overlay=True, style={"display": "inline-block", "margin": "5px 5px 5px 0"}, service=None) 28 | self.images.append(kimage) 29 | div = html.Div(kimage.layout, id=self.kapp.new_id(), style={"display": "inline-block"}) 30 | 31 | def func(_kimage): 32 | def func_(): 33 | path = _kimage.path 34 | mods = [] 35 | try: 36 | title = _kimage.data['timestamp'] 37 | except: 38 | title = "" 39 | if path.endswith(".mp4"): 40 | mods += self.dialog_video.out_src(path) + self.video_dialog.out_title(title) + self.video_dialog.out_open(True) 41 | else: # regular image 42 | mods += self.dialog_image.out_src(path) + self.image_dialog.out_title(title) + self.image_dialog.out_open(True) + self.render(self.dialog_image, _kimage.data) 43 | return mods 44 | return func_ 45 | 46 | kimage.callback()(func(kimage)) 47 | children.append(div) 48 | return children 49 | 50 | def render(self, kimage, data): 51 | kimage.overlay.draw_clear() 52 | try: 53 | if kimage.path.endswith(".mp4"): 54 | # create play arrow in overlay 55 | ARROW_WIDTH = 0.18 56 | ARROW_HEIGHT = ARROW_WIDTH*1.5 57 | xoffset0 = (1-ARROW_WIDTH)*data['width']/2 58 | xoffset1 = xoffset0 + ARROW_WIDTH*data['width'] 59 | yoffset0 = (data['height'] - ARROW_HEIGHT*data['width'])/2 60 | yoffset1 = yoffset0 + ARROW_HEIGHT*data['width']/2 61 | yoffset2 = yoffset1 + ARROW_HEIGHT*data['width']/2 62 | points = [(xoffset0, yoffset0), (xoffset0, yoffset2), (xoffset1, yoffset1)] 63 | kimage.overlay.draw_shape(points, fillcolor="rgba(255,255,255,0.85)", line={"width": 0}) 64 | except: 65 | pass 66 | try: 67 | kimage.overlay.update_resolution(width=data['width'], height=data['height']) 68 | kritter.render_detected(kimage.overlay, data['dets'], scale=self.media_display_width/self.media_width, font_size=self.font_size) 69 | except: 70 | pass 71 | try: 72 | kimage.overlay.draw_text(0, data['height']-1, data['timestamp'], fillcolor="black", font=dict(family="sans-serif", size=12, color="white"), xanchor="left", yanchor="bottom") 73 | except: 74 | pass 75 | return kimage.overlay.out_draw() 76 | 77 | def get_images_and_data(self): 78 | images = os.listdir(self.media_dir) 79 | images = [i for i in images if i.endswith(".jpg") or i.endswith(".mp4")] 80 | images.sort(reverse=True) 81 | 82 | images_and_data = [] 83 | for image in images: 84 | data = kritter.load_metadata(os.path.join(self.media_dir, image)) 85 | images_and_data.append((image, data)) 86 | if len(images_and_data)==self.num_media: 87 | break 88 | return images_and_data 89 | 90 | def set_media_dir(self, media_dir): 91 | if media_dir: 92 | self.media_dir = media_dir 93 | self.kapp.media_path.insert(0, self.media_dir) 94 | 95 | def dialog_image_callback(self, state=()): 96 | def wrap_func(func): 97 | @self.dialog_image.callback(state) 98 | def _func(*args): 99 | return func(self.dialog_image.src, self.dialog_image.srcpath, *args) 100 | return wrap_func 101 | 102 | def out_disp(self, disp): 103 | return [Output(self.layout.id, "style", {"display": "block"})] if disp else [Output(self.layout.id, "style", {"display": "none"})] 104 | 105 | def out_images(self): 106 | images_and_data = self.get_images_and_data() 107 | mods = [] 108 | for i in range(self.num_media): 109 | if i < len(images_and_data): 110 | image, data = images_and_data[i] 111 | self.images[i].path = image 112 | self.images[i].data = data 113 | if image.endswith(".mp4"): 114 | image = data['thumbnail'] 115 | mods += self.images[i].out_src(image) 116 | mods += self.render(self.images[i], data) 117 | mods += self.images[i].out_disp(True) 118 | else: 119 | mods += self.images[i].out_disp(False) 120 | return mods 121 | -------------------------------------------------------------------------------- /src/vizy/newprojectdialog.py: -------------------------------------------------------------------------------- 1 | import kritter 2 | 3 | class NewProjectDialog(kritter.Kdialog): 4 | def __init__(self, get_projects, title=[kritter.Kritter.icon("folder"), "New project"], overwritable=False): 5 | self.get_projects = get_projects 6 | self.name = '' 7 | self.callback_func = None 8 | name = kritter.KtextBox(placeholder="Enter project name") 9 | save_button = kritter.Kbutton(name=[kritter.Kritter.icon("save"), "Save"], disabled=True) 10 | dialog_text = kritter.Ktext(style={"control_width": 12}) 11 | if overwritable: 12 | dialog = kritter.KyesNoDialog(title="Overwrite project?", layout=dialog_text, shared=True) 13 | else: 14 | dialog = kritter.KokDialog(title="Project exists", layout=dialog_text, shared=True) 15 | 16 | name.append(save_button) 17 | super().__init__(title=title, close_button=[kritter.Kritter.icon("close"), "Cancel"], layout=[name, dialog], shared=True) 18 | 19 | @self.callback_view() 20 | def func(state): 21 | if not state: 22 | return name.out_value("") 23 | 24 | @name.callback() 25 | def func(val): 26 | if val: 27 | self.name = val 28 | return save_button.out_disabled(not bool(val)) 29 | 30 | @save_button.callback() 31 | def func(): 32 | projects = self.get_projects() 33 | if self.name in projects: 34 | if overwritable: 35 | return dialog_text.out_value(f'"{self.name}" exists. Do you want to overwrite?') + dialog.out_open(True) 36 | else: 37 | return dialog_text.out_value(f'"{self.name}" already exists.') + dialog.out_open(True) 38 | 39 | mods = [] 40 | if self.callback_func: 41 | res = self.callback_func(self.name) 42 | if isinstance(res, list): 43 | mods += res 44 | return self.out_open(False) + mods 45 | 46 | if overwritable: 47 | @dialog.callback_response() 48 | def func(val): 49 | if val: 50 | self.kapp.push_mods(self.out_open(False)) 51 | if self.callback_func: 52 | self.callback_func(self.name) 53 | 54 | def callback_project(self): 55 | def wrap_func(func): 56 | self.callback_func = func 57 | return wrap_func 58 | -------------------------------------------------------------------------------- /src/vizy/openprojectdialog.py: -------------------------------------------------------------------------------- 1 | import kritter 2 | 3 | class OpenProjectDialog(kritter.Kdialog): 4 | def __init__(self, get_projects, title=[kritter.Kritter.icon("folder-open"), "Open project"]): 5 | self.get_projects = get_projects 6 | self.selection = '' 7 | self.callback_func = None 8 | open_button = kritter.Kbutton(name=[kritter.Kritter.icon("folder-open"), "Open"], disabled=True) 9 | delete_button = kritter.Kbutton(name=[kritter.Kritter.icon("trash"), "Delete"], disabled=True) 10 | delete_text = kritter.Ktext(style={"control_width": 12}) 11 | yesno = kritter.KyesNoDialog(title="Delete project?", layout=delete_text, shared=True) 12 | select = kritter.Kdropdown(value=None, placeholder="Select project...") 13 | select.append(open_button) 14 | select.append(delete_button) 15 | super().__init__(title=title, layout=[select, yesno], shared=True) 16 | 17 | @self.callback_view() 18 | def func(state): 19 | if state: 20 | return select.out_options(self.get_projects(True)) 21 | else: 22 | return select.out_value(None) 23 | 24 | @select.callback() 25 | def func(selection): 26 | self.selection = selection 27 | disabled = not bool(selection) 28 | return open_button.out_disabled(disabled) + delete_button.out_disabled(disabled) 29 | 30 | @open_button.callback() 31 | def func(): 32 | mods = [] 33 | if self.callback_func: 34 | res = self.callback_func(self.selection, False) 35 | if isinstance(res, list): 36 | mods += res 37 | return self.out_open(False) + mods 38 | 39 | @delete_button.callback() 40 | def func(): 41 | return delete_text.out_value(f'Are you sure you want to delete "{self.selection}" project?') + yesno.out_open(True) 42 | 43 | @yesno.callback_response() 44 | def func(val): 45 | if val: 46 | mods = [] 47 | if self.callback_func: 48 | res = self.callback_func(self.selection, True) 49 | if isinstance(res, list): 50 | mods += res 51 | projects = self.get_projects(True) 52 | return select.out_options(projects) + select.out_value(None) 53 | 54 | def callback_project(self): 55 | def wrap_func(func): 56 | self.callback_func = func 57 | return wrap_func 58 | -------------------------------------------------------------------------------- /src/vizy/perspective.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Vizy 3 | # 4 | # All Vizy source code is provided under the terms of the 5 | # GNU General Public License v2 (http://www.gnu.org/licenses/gpl-2.0.html). 6 | # Those wishing to use Vizy source code, software and/or 7 | # technologies under different licensing terms should contact us at 8 | # support@charmedlabs.com. 9 | # 10 | 11 | import kritter 12 | from dash_devices.dependencies import Output, State 13 | import dash_bootstrap_components as dbc 14 | import dash_html_components as html 15 | import math 16 | import numpy as np 17 | import cv2 18 | from kritter import Kritter 19 | 20 | GRID_DIVS = 20 21 | I_MATRIX = np.identity(3, dtype="float32") 22 | 23 | def line_x(x0, y0, x1, y1, x): 24 | if x1==x0: 25 | x1 = x0+1e-10 26 | return (x-x0)*(y1-y0)/(x1-x0)+y0 27 | 28 | def line_y(x0, y0, x1, y1, y): 29 | if y1==y0: 30 | y1 = y0+1e-10 31 | return (y-y0)*(x1-x0)/(y1-y0)+x0 32 | 33 | class Perspective: 34 | 35 | def __init__(self, video, f, video_info, style={}, closed=True, shift=True, shear=True, kapp=None): 36 | self.kapp = Kritter.kapp if kapp is None else kapp 37 | self.id = self.kapp.new_id("Perspective") 38 | self.callback_change_func = None 39 | self.matrix = I_MATRIX 40 | style_ = style 41 | style = kritter.default_style 42 | style.update(style_) 43 | self.video = video 44 | self.grid = False 45 | self.pixelsize = 1 46 | self.resolution = [0, 0] 47 | self.crop = [1, 1] 48 | self.offset = [0, 0] 49 | self.f = f 50 | self.shear = [0, 0] 51 | self.reset() 52 | self.video_info_table = None 53 | 54 | control_style = style 55 | style = style.copy() 56 | style['control_width'] = 0.1 57 | self.enable = not closed 58 | self.enable_c = kritter.Kcheckbox(name="Perspective", value=not closed, style=style) 59 | self.more_c = kritter.Kbutton(name=Kritter.icon("plus", padding=0), size="sm", disabled=closed) 60 | self.enable_c.append(self.more_c) 61 | self.grid_c = kritter.Kcheckbox(name="Show grid", value=False, style=style) 62 | self.reset_c = kritter.Kbutton(name=[Kritter.icon("undo"), "Reset"], size="sm") 63 | more_shear = kritter.Kbutton(name=[Kritter.icon("plus"), "Shear"], size="sm") 64 | if shear: 65 | self.reset_c.append(more_shear) 66 | self.roll_c = kritter.Kslider(name="Roll", value=self.roll, mxs=(-100, 100, 0.1), format=lambda val: f'{val:.1f}°',style=control_style, ) 67 | self.pitch_c = kritter.Kslider(name="Pitch", value=self.pitch, mxs=(-45, 45, 0.1), format=lambda val: f'{val:.1f}°', style=control_style) 68 | self.yaw_c = kritter.Kslider(name="Yaw", value=self.yaw, mxs=(-45, 45, 0.1), format=lambda val: f'{val:.1f}°', style=control_style) 69 | self.zoom_c = kritter.Kslider(name="Zoom", value=self.zoom, mxs=(0.5, 10, 0.01), format=lambda val: f'{val:.1f}x', style=control_style) 70 | self.shift_x_c = kritter.Kslider(name="Shift x", value=self.shift[0], mxs=(-1, 1, 0.01), format=lambda val: f'{round(val*100)}%', style=control_style) 71 | self.shift_y_c = kritter.Kslider(name="Shift y", value=self.shift[1], mxs=(-1, 1, 0.01), format=lambda val: f'{round(val*100)}%', style=control_style) 72 | self.shear_x_c = kritter.Kslider(name="Shear x", value=self.shear[0], mxs=(-1, 1, 0.01), style=control_style) 73 | self.shear_y_c = kritter.Kslider(name="Shear y", value=self.shear[1], mxs=(-1, 1, 0.01), style=control_style) 74 | 75 | controls = [self.roll_c, self.pitch_c, self.yaw_c, self.zoom_c] 76 | if shift: 77 | controls += [self.shift_x_c, self.shift_y_c] 78 | controls += [self.grid_c, self.reset_c] 79 | if shear: 80 | collapse_shear = dbc.Collapse([self.shear_x_c, self.shear_y_c] ,id=Kritter.new_id()) 81 | controls += [collapse_shear] 82 | self.collapse = dbc.Collapse(dbc.Card(controls, style={"margin-left": f"{style['horizontal_padding']}px", "margin-right": f"{style['horizontal_padding']}px"}), id=Kritter.new_id()) 83 | self.layout = html.Div([self.enable_c, self.collapse], id=Kritter.new_id()) 84 | # Initialize mode 85 | self.set_video_info(video_info) 86 | 87 | @more_shear.callback([State(collapse_shear.id, "is_open")]) 88 | def func(is_open): 89 | return more_shear.out_name([Kritter.icon("plus"), "Shear"] if is_open else [Kritter.icon("minus"), "Shear"]) + [Output(collapse_shear.id, "is_open", not is_open)] 90 | 91 | @self.more_c.callback([State(self.collapse.id, "is_open")]) 92 | def func(is_open): 93 | return self.set_more(not is_open) 94 | 95 | @self.enable_c.callback() 96 | def func(value): 97 | self.enable = value 98 | mods = [] 99 | if value: 100 | self.calc_matrix() 101 | mods += self.draw_grid() 102 | else: 103 | self.set_matrix(I_MATRIX) 104 | self.video.overlay.draw_clear_shapes(self.id) 105 | mods += self.set_more(False) + self.video.overlay.out_draw() 106 | mods += self.more_c.out_disabled(not value) 107 | return mods 108 | 109 | @self.roll_c.callback() 110 | def func(value): 111 | self.roll = value 112 | self.calc_matrix() 113 | 114 | @self.pitch_c.callback() 115 | def func(value): 116 | self.pitch = value 117 | self.calc_matrix() 118 | 119 | @self.yaw_c.callback() 120 | def func(value): 121 | self.yaw = value 122 | self.calc_matrix() 123 | 124 | @self.zoom_c.callback() 125 | def func(value): 126 | self.zoom = value 127 | self.calc_matrix() 128 | 129 | @self.shift_x_c.callback() 130 | def func(value): 131 | self.shift[0] = value 132 | self.calc_matrix() 133 | 134 | @self.shift_y_c.callback() 135 | def func(value): 136 | self.shift[1] = value 137 | self.calc_matrix() 138 | 139 | @self.shear_x_c.callback() 140 | def func(value): 141 | self.shear[0] = value 142 | self.calc_matrix() 143 | 144 | @self.shear_y_c.callback() 145 | def func(value): 146 | self.shear[1] = value 147 | self.calc_matrix() 148 | 149 | @self.reset_c.callback() 150 | def func(): 151 | self.reset() # reset values first -- there can be a race condition. 152 | return self.roll_c.out_value(0) + self.pitch_c.out_value(0) + self.yaw_c.out_value(0) + self.zoom_c.out_value(1) + self.shift_x_c.out_value(0) + self.shift_y_c.out_value(0) + self.grid_c.out_value(False) 153 | 154 | @self.grid_c.callback() 155 | def func(value): 156 | self.grid = value 157 | return self.draw_grid() 158 | 159 | def out_enable(self, enable): 160 | return self.enable_c.out_value(enable) 161 | 162 | def out_disp(self, state): 163 | style = {'display': 'block'} if state else {'display': 'none'} 164 | return [Output(self.layout.id, "style", style)] 165 | 166 | def out_reset(self): 167 | return self.reset_c.out_click() 168 | 169 | def callback_change(self): 170 | def wrap_func(func): 171 | self.callback_change_func = func 172 | return wrap_func 173 | 174 | def reset(self): 175 | self.set_matrix(I_MATRIX) 176 | self.roll = 0 177 | self.pitch = 0 178 | self.yaw = 0 179 | self.zoom = 1 180 | self.shift = [0, 0] 181 | 182 | def draw_grid(self): 183 | mods = [] 184 | self.video.overlay.draw_clear_shapes(self.id) 185 | if self.grid: 186 | step = self.resolution[0]/(GRID_DIVS+1) 187 | # range() doesn't work with floating point numbers... 188 | range_ = [step//2 + int(i*step) for i in range(GRID_DIVS+1)] 189 | for i in range_: 190 | self.video.overlay.draw_line(i, 0, i, self.resolution[1], line={"color": f"rgba(0, 255, 0, 0.25)", "width": 2}, id=self.id) 191 | for i in range_: 192 | self.video.overlay.draw_line(0, i, self.resolution[0], i, line={"color": f"rgba(0, 255, 0, 0.25)", "width": 2}, id=self.id) 193 | return self.video.overlay.out_draw() 194 | 195 | def set_more(self, val): 196 | return self.more_c.out_name(Kritter.icon("minus", padding=0) if val else Kritter.icon("plus", padding=0)) + [Output(self.collapse.id, "is_open", val)] 197 | 198 | def calc_roll(self): 199 | roll = self.roll*math.pi/180 200 | croll = math.cos(roll) 201 | sroll = math.sin(roll) 202 | T1 = np.float32([[1, 0, self.resolution[0]/2], [0, 1, self.resolution[1]/2], [0, 0, 1]]) 203 | R = np.float32([[croll, -sroll, 0], [sroll, croll, 0], [0, 0, 1]]) 204 | T2 = np.float32([[1, 0, -self.resolution[0]/2], [0, 1, -self.resolution[1]/2], [0, 0, 1]]) 205 | Z = np.float32([[self.zoom, 0, 0], [0, self.zoom, 0], [0, 0, 1]]) 206 | return T1@R@Z@T2 207 | 208 | def calc_pitch_yaw(self): 209 | center_x = self.resolution[0]*(1 + self.shear[0]*self.crop[0] + self.offset[0])/2 210 | center_y = self.resolution[1]*(1 + self.shear[1]*self.crop[1] + self.offset[1])/2 211 | 212 | pitch = self.pitch*math.pi/180 213 | yaw = self.yaw*math.pi/180 214 | if pitch==0: 215 | x0 = 0 216 | x1 = self.resolution[0] 217 | else: 218 | vanish = center_x, self.f/math.tan(pitch) + center_y 219 | x0 = line_y(0, self.resolution[1], vanish[0], vanish[1], 0) 220 | x1 = line_y(self.resolution[0], self.resolution[1], vanish[0], vanish[1], 0) 221 | if yaw==0: 222 | y0 = 0 223 | y1 = self.resolution[1] 224 | else: 225 | vanish = self.f/math.tan(yaw) + center_x, center_y 226 | y0 = line_x(self.resolution[0], 0, vanish[0], vanish[1], 0) 227 | y1 = line_x(self.resolution[0], self.resolution[1], vanish[0], vanish[1], 0) 228 | p_in = np.float32([[0, y1], [self.resolution[0], self.resolution[1]], [x1, 0], [x0, y0]]) 229 | phi = math.atan(self.resolution[1]/2/self.f) 230 | y_stretch = math.sin(math.pi/2+phi)/math.sin(math.pi/2+pitch-phi) 231 | w = self.resolution[0]/y_stretch 232 | x_offset = (self.resolution[0] - w)/2 233 | phi = math.atan(self.resolution[0]/2/self.f) 234 | x_stretch = math.sin(math.pi/2+phi)/math.sin(math.pi/2+yaw-phi) 235 | h = self.resolution[1]/x_stretch 236 | y_offset = (self.resolution[1] - h)/2 237 | p_out = np.float32([[x_offset, self.resolution[1]-y_offset], [self.resolution[0]-x_offset, self.resolution[1]-y_offset], [self.resolution[0]-x_offset, y_offset], [x_offset, y_offset]]) 238 | return cv2.getPerspectiveTransform(p_in, p_out) 239 | 240 | def set_matrix(self, matrix): 241 | if not np.allclose(matrix, self.matrix): 242 | self.matrix = matrix 243 | if self.callback_change_func: 244 | self.callback_change_func(self.matrix) 245 | 246 | def calc_matrix(self): 247 | matrix = np.float32([[1, 0, self.shift[0]*self.resolution[0]], [0, 1, self.shift[1]*self.resolution[1]], [0, 0, 1]])@self.calc_roll()@self.calc_pitch_yaw() 248 | self.set_matrix(matrix) 249 | 250 | def get_params(self): 251 | return {"enable": self.enable, "roll": self.roll, "pitch": self.pitch, "yaw": self.yaw, "zoom": self.zoom, "shift": self.shift, "shear": self.shear, "grid": self.grid} 252 | 253 | def set_params(self, value): 254 | for k, v in value.items(): 255 | try: 256 | setattr(self, k, v) 257 | except: 258 | pass 259 | self.calc_matrix() 260 | return self.out_enable(self.enable) + self.roll_c.out_value(self.roll) + self.pitch_c.out_value(self.pitch) + self.yaw_c.out_value(self.yaw) + self.zoom_c.out_value(self.zoom) + self.shift_x_c.out_value(self.shift[0]) + self.shift_y_c.out_value(self.shift[1]) + self.shear_x_c.out_value(self.shear[0]) + self.shear_y_c.out_value(self.shear[1]) + self.grid_c.out_value(self.grid) 261 | 262 | def set_intrinsics(self, f, shear_x, shear_y): 263 | self.f = f 264 | self.shear[0] = shear_x 265 | self.shear[1] = shear_y 266 | 267 | def set_video_info(self, info): 268 | self.resolution = info['resolution'] 269 | self.crop = info['crop'] 270 | self.offset = info['offset'] 271 | pixelsize = info['pixelsize'][0] 272 | self.f *= self.pixelsize/pixelsize 273 | self.pixelsize = pixelsize 274 | if self.enable: 275 | self.calc_matrix() 276 | return self.draw_grid() 277 | else: 278 | return [] 279 | 280 | def set_video_info_modes(self, modes): 281 | # Create lookup table with resolution as index. 282 | self.video_info_table = {m['resolution']: m for m in modes} 283 | 284 | def transform(self, image): 285 | if self.video_info_table: 286 | resolution = (image.shape[1], image.shape[0]) 287 | if resolution!=self.resolution: 288 | self.kapp.push_mods(self.set_video_info(self.video_info_table[resolution])) 289 | return image if np.allclose(self.matrix, I_MATRIX) else cv2.warpPerspective(image, self.matrix, self.resolution, flags=cv2.INTER_LINEAR) 290 | -------------------------------------------------------------------------------- /src/vizy/rebootdialog.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Vizy 3 | # 4 | # All Vizy source code is provided under the terms of the 5 | # GNU General Public License v2 (http://www.gnu.org/licenses/gpl-2.0.html). 6 | # Those wishing to use Vizy source code, software and/or 7 | # technologies under different licensing terms should contact us at 8 | # support@charmedlabs.com. 9 | # 10 | 11 | import os 12 | import time 13 | from threading import Thread 14 | import dash_bootstrap_components as dbc 15 | from dash_devices.dependencies import Output 16 | from dash_devices import callback_context 17 | import vizy.vizypowerboard as vpb 18 | from kritter import Kritter, Ktext, Kradio, Kdialog, Kslider, Kbutton, KsideMenuItem 19 | 20 | # The on time can't be less than the MIN_TIME in the future, otherwise, there might not 21 | # be enough time to shut down! 22 | MIN_TIME = 30 # seconds 23 | 24 | class RebootDialog: 25 | 26 | def __init__(self, kapp, pmask): 27 | self.kapp = kapp 28 | self.run = 0 29 | self.thread = None 30 | self.time = 0 31 | self.ontime = 0 32 | self.seconds = self.minutes = self.hours = self.days = 0 33 | 34 | style = {"label_width": 2, "control_width": 9} 35 | self.options = ["Reboot", "Power off", "Power off, on"] 36 | self.type_c = Kradio(value=self.options[0], options=self.options, style=style) 37 | self.apply = Kbutton(name=[Kritter.icon("check-square-o"), "Apply"], spinner=True) 38 | self.seconds_c = Kslider(name="Seconds", value=0, mxs=(0, 59, 1)) 39 | self.minutes_c = Kslider(name="Minutes", value=0, mxs=(0, 59, 1)) 40 | self.hours_c = Kslider(name="Hours", value=0, mxs=(0, 23, 1)) 41 | self.days_c = Kslider(name="Days", value=0, mxs=(0, 30, 1)) 42 | self.ontime_c = Ktext(name="Turn-on time") 43 | self.currtime_c = Ktext(name="Current time") 44 | self.on_controls = dbc.Collapse([self.currtime_c, self.ontime_c, self.seconds_c, self.minutes_c, self.hours_c, self.days_c], id=Kritter.new_id(), is_open=False) 45 | layout = [self.type_c, self.on_controls] 46 | dialog = Kdialog(title=[Kritter.icon("power-off"), "Reboot/power"], layout=layout, left_footer=self.apply, close_button=[Kritter.icon("close"), "Cancel"]) 47 | self.layout = KsideMenuItem("Reboot/power", dialog, "power-off") 48 | 49 | @dialog.callback_view() 50 | def func(open): 51 | if open: 52 | self.run += 1 53 | if self.run==1: 54 | self.thread = Thread(target=self.update_thread) 55 | self.thread.start() 56 | return self.type_c.out_value(self.options[0]) 57 | elif self.run>0: # Stale dialogs in browser can result in negative counts. 58 | self.run -= 1 59 | 60 | @self.type_c.callback() 61 | def func(type): 62 | if type==self.options[2]: # off, on 63 | return [Output(self.on_controls.id, "is_open", True)] + self.update() 64 | else: 65 | return Output(self.on_controls.id, "is_open", False) 66 | 67 | @self.seconds_c.callback() 68 | def func(seconds): 69 | self.seconds = seconds 70 | return self.update() 71 | 72 | @self.minutes_c.callback() 73 | def func(minutes): 74 | self.minutes = minutes 75 | return self.update() 76 | 77 | @self.hours_c.callback() 78 | def func(hours): 79 | self.hours = hours 80 | return self.update() 81 | 82 | @self.days_c.callback() 83 | def func(days): 84 | self.days = days 85 | return self.update() 86 | 87 | @self.apply.callback(self.type_c.state_value()) 88 | def func(type): 89 | if not callback_context.client.authentication&pmask: 90 | return 91 | # This is a bit of reassuring feedback that the reboot/power down is taking effect. 92 | self.kapp.push_mods(self.apply.out_spinner_disp(True)) 93 | time.sleep(2) 94 | # More feedback... 95 | self.kapp.push_mods(self.kapp.out_main(None)) 96 | # Give it time to propagate to the browser before we get terminated. 97 | time.sleep(1) 98 | if type==self.options[0]: # Reboot 99 | os.system("reboot now") 100 | elif type==self.options[1]: # Power off 101 | self.kapp.power_board.power_off_requested(True) 102 | else: # Power off then on 103 | # Set power on alarm accordingly. 104 | self.kapp.power_board.power_on_alarm_seconds(self.ontime - time.time()) 105 | self.kapp.power_board.power_off_requested(True) 106 | 107 | def update_thread(self): 108 | while(self.run): 109 | self.kapp.push_mods(self.update()) 110 | time.sleep(1) 111 | 112 | def update(self): 113 | t0 = time.time() 114 | t1 = self.seconds + self.minutes*60 + self.hours*60*60 + self.days*60*60*24 115 | if self.time+t1-t0100 else u # Rounding errors can result in 101% 84 | get_cpu_usage.t0 = t 85 | get_cpu_usage.usage0 = usage 86 | except: 87 | pass 88 | res = [round(u) for u in res] 89 | return res 90 | 91 | get_cpu_usage.t0 = 0 92 | 93 | def get_cpu_info(): 94 | try: 95 | with open('/proc/device-tree/model', 'r') as f: 96 | return f.readline().strip()[0:-1] 97 | except: 98 | pass 99 | return "" 100 | 101 | 102 | class SystemDialog: 103 | 104 | def __init__(self, kapp, tv, pmask): 105 | self.kapp = kapp 106 | self.run = 0 107 | self.thread = None 108 | 109 | style = {"label_width": 4, "control_width": 8} 110 | cam_config = self.kapp.vizy_config['hardware']['camera'] 111 | cam_desc = f"{cam_config['type']} with {cam_config['IR-cut']} IR-cut, Rev {cam_config['version']}" 112 | pb_ver = self.kapp.power_board.hw_version() 113 | fw_ver = self.kapp.power_board.fw_version() 114 | flash_total, flash_free = get_flash() 115 | # SD cards are in SI units for giga (10^9) instead of binary (2^23) 116 | # We don't dynamically update flash numbers. 117 | flash = f"{round(flash_total*1024/pow(10, 9))} GB, {flash_free*1024/pow(10, 9):.4f} GB free" 118 | self.cpu_c = Ktext(name="CPU", value=get_cpu_info(), style=style) 119 | self.camera_c = Ktext(name="Camera", value=cam_desc, style=style) 120 | self.power_board_c = Ktext(name="Power board", value=f"PCB rev {pb_ver[0]}.{pb_ver[1]}, firmware ver {fw_ver[0]}.{fw_ver[1]}.{fw_ver[2]}", style=style) 121 | self.flash_c = Ktext(name="Flash", value=flash, style=style) 122 | self.ram_c = Ktext(name="RAM", style=style) 123 | self.cpu_usage_c = Ktext(name="CPU usage", style=style) 124 | self.cpu_temp_c = Ktext(name="CPU temperature", style=style) 125 | self.voltage_input_c = Ktext(name="Input voltage", style=style) 126 | self.voltage_5v_c = Ktext(name="5V voltage", style=style) 127 | self.ext_button_c = Kcheckbox(name="External button", value=self.ext_button(), disp=False, style=style, service=None) 128 | power_button_mode_map = {"Power on when button pressed": vpb.DIPSWITCH_POWER_DEFAULT_OFF, "Power on when power applied": vpb.DIPSWITCH_POWER_DEFAULT_ON, "Remember power state": vpb.DIPSWITCH_POWER_SWITCH, "Always on, power-off disabled": vpb.DIPSWITCH_POWER_PLUG} 129 | power_button_mode_map2 = {v: k for k, v in power_button_mode_map.items()} 130 | power_button_modes = [k for k, v in power_button_mode_map.items()] 131 | try: 132 | value = power_button_mode_map2[self.power_button_mode()] 133 | except: 134 | value = "Unknown" 135 | self.power_button_mode_c = Kdropdown(name="Power on behavior", options=power_button_modes, value=value, style=style) 136 | 137 | layout = [self.cpu_c, self.camera_c, self.power_board_c, self.flash_c, self.ram_c, self.cpu_usage_c, self.cpu_temp_c, self.voltage_input_c, self.voltage_5v_c, self.ext_button_c, self.power_button_mode_c] 138 | dialog = Kdialog(title=[Kritter.icon("gears"), "System Information"], layout=layout) 139 | self.layout = KsideMenuItem("System", dialog, "gears") 140 | 141 | @dialog.callback_view() 142 | def func(open): 143 | if open: 144 | self.run += 1 145 | if self.run==1: 146 | self.thread = Thread(target=self.update_thread) 147 | self.thread.start() 148 | elif self.run>0: # Stale dialogs in browser can result in negative counts. 149 | self.run -= 1 150 | 151 | @self.kapp.callback_connect 152 | def func(client, connect): 153 | if connect: 154 | # Being able to change the button configuration is privileged. 155 | return self.ext_button_c.out_disp(client.authentication&pmask) + self.power_button_mode_c.out_disp(client.authentication&pmask) 156 | 157 | @self.ext_button_c.callback() 158 | def func(val): 159 | # Being able to change the button configuration is privileged. 160 | if not callback_context.client.authentication&pmask: 161 | return 162 | self.ext_button(val) 163 | 164 | @self.power_button_mode_c.callback() 165 | def func(val): 166 | # Being able to change the button button mode is privileged. 167 | if not callback_context.client.authentication&pmask: 168 | return 169 | self.power_button_mode(power_button_mode_map[val]) 170 | 171 | # setup KtextClient keywords, callbacks, and descriptions 172 | def system_info(words, sender, context): 173 | sysinfo = self.get_system_info(1) 174 | info = {} # format to str -- ktextVisor ln.133 | TypeError: can only concatenate str (not <"int", "float", "list">) to str 175 | info['cpu-usage'] = ' '.join([f"{c}%" for c in sysinfo['cpu']['usage']]) + f" ({sum(sysinfo['cpu']['usage'])})%" 176 | info['cpu-temp'] = f"{sysinfo['cpu']['temp']:.1f}\u00b0C, {sysinfo['cpu']['temp']*1.8+32:.1f}\u00b0F" 177 | info['ram'] = f"{round(sysinfo['ram']['total']/(1<<20))} GB, {sysinfo['ram']['free']/(1<<20):.4f} GB free" 178 | # flash memory | SD cards are in SI units for giga (10^9) instead of binary (2^23) 179 | info['flash'] = f"{round(sysinfo['flash']['total']*1024/pow(10, 9))} GB, {sysinfo['flash']['free']*1024/pow(10, 9):.4f} GB free" 180 | info['voltage'] = ' '.join([f"{v}: {sysinfo['voltage'][v]:.2f}V" for v in sysinfo['voltage']]) 181 | return info 182 | 183 | tv_table = KtextVisorTable({ 184 | "sysinfo" : (system_info, "Prints current system information.")}) 185 | @tv.callback_receive() 186 | def func(words, sender, context): 187 | return tv_table.lookup(words, sender, context) 188 | 189 | 190 | def ext_button(self, value=None): 191 | if value is None: 192 | return bool(self.kapp.power_board.dip_switches()&vpb.DIPSWITCH_EXT_BUTTON) 193 | _value = self.kapp.power_board.dip_switches() 194 | if (value): 195 | _value |= vpb.DIPSWITCH_EXT_BUTTON 196 | else: 197 | _value &= ~vpb.DIPSWITCH_EXT_BUTTON 198 | self.kapp.power_board.dip_switches(_value) 199 | 200 | def power_button_mode(self, value=None): 201 | if value is None: 202 | return self.kapp.power_board.dip_switches()&vpb.DIPSWITCH_POWER_PLUG 203 | _value = self.kapp.power_board.dip_switches() 204 | _value &= ~vpb.DIPSWITCH_POWER_PLUG 205 | _value |= value 206 | self.kapp.power_board.dip_switches(_value) 207 | 208 | def update_thread(self): 209 | while(self.run): 210 | self.kapp.push_mods(self.update()) 211 | time.sleep(1) 212 | 213 | def update(self): 214 | '''fetches system information and updates GUI''' 215 | system_info = self.get_system_info() 216 | # format fields 217 | style = {"width": "45px", "float": "left"} 218 | cpu_temp = f"{system_info['cpu']['temp']:.1f}\u00b0C, {system_info['cpu']['temp']*1.8+32:.1f}\u00b0F" 219 | ram = f"{round(system_info['ram']['total']/(1<<20))} GB, {system_info['ram']['free']/(1<<20):.4f} GB free" 220 | cpu_usage = [html.Span(f"{u}%", style=style) for u in system_info['cpu']['usage']] 221 | cpu_usage.append(html.Span(f"{sum(system_info['cpu']['usage'])}%")) 222 | voltage_5v = f"{system_info['voltage']['5v']:.2f}V" 223 | voltage_input = f"{system_info['voltage']['input']:.2f}V" 224 | # return mods to push 225 | return self.ram_c.out_value(ram) + \ 226 | self.cpu_usage_c.out_value(cpu_usage) + \ 227 | self.voltage_5v_c.out_value(voltage_5v) + \ 228 | self.voltage_input_c.out_value(voltage_input) + \ 229 | self.cpu_temp_c.out_value(cpu_temp) 230 | 231 | def get_system_info(self, period=0): 232 | '''returns dict of current system information''' 233 | if period: 234 | # get_cpu_usage needs an averaging period to make an accurate measurement 235 | get_cpu_usage() 236 | time.sleep(period) 237 | cpu_usage = get_cpu_usage() 238 | cpu_temp = vpb.get_cpu_temp() 239 | ram_total, ram_free = get_ram() 240 | flash_total, flash_free = get_flash() 241 | voltage_5v = self.kapp.power_board.measure(vpb.CHANNEL_5V) 242 | voltage_input = self.kapp.power_board.measure(vpb.CHANNEL_VIN) 243 | return { 244 | 'cpu': { 'temp' : cpu_temp, 'usage' : cpu_usage }, 245 | 'ram': { 'total' : ram_total, 'free' : ram_free }, 246 | 'flash': { 'total': flash_total, 'free': flash_free }, 247 | 'voltage': { '5v': voltage_5v, 'input': voltage_input } 248 | } 249 | 250 | def close(self): 251 | self.run = 0 252 | -------------------------------------------------------------------------------- /src/vizy/test.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmedlabs/vizy/59e443cf61150b3b4072c1817665757b1aad611b/src/vizy/test.jpg -------------------------------------------------------------------------------- /src/vizy/textingdialog.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Vizy 3 | # 4 | # All Vizy source code is provided under the terms of the 5 | # GNU General Public License v2 (http://www.gnu.org/licenses/gpl-2.0.html). 6 | # Those wishing to use Vizy source code, software and/or 7 | # technologies under different licensing terms should contact us at 8 | # support@charmedlabs.com. 9 | # 10 | 11 | import os 12 | import dash_core_components as dcc 13 | from kritter import Kritter, KtextBox, Ktext, Kbutton, Kdialog, KsideMenuItem, KyesNoDialog, Kdropdown 14 | from kritter.ktextvisor import KtextVisor, KtextVisorTable, Response, Image 15 | from kritter import Kritter 16 | from dash_devices import callback_context 17 | 18 | HELP_URL = "https://docs.vizycam.com/doku.php?id=wiki:texting" 19 | 20 | class TextingDialog: 21 | def __init__(self, kapp, tv, pmask): 22 | self.kapp = kapp 23 | 24 | # Initialize Client and define callback_receive 25 | self.text_visor = tv 26 | 27 | style = {"label_width": 2, "control_width": 6} 28 | 29 | # Set token components 30 | self.token_text = KtextBox(name="Token", placeholder="Paste Token Here", style=style, grid=False) 31 | self.submit_token = Kbutton(name=[Kritter.icon('thumbs-up'), "Submit"], spinner=True) 32 | self.token_text.append(self.submit_token) 33 | 34 | # Remove, test components 35 | self.remove_token = Kbutton(name=[Kritter.icon("remove"), "Remove Token"]) 36 | self.test = Kbutton(name=[Kritter.icon("commenting"), "Send test text"]) 37 | self.remove_token.append(self.test) 38 | 39 | # Subscriber List, manageable list of message recipients 40 | self.subscriber_selection = '' 41 | self.delete_button = Kbutton(name=[Kritter.icon("trash"), "Delete"], disabled=True) 42 | self.delete_text = Ktext(style={"control_width": 12}) 43 | self.delete_subscriber_yesno = KyesNoDialog(title="Delete Subscriber?", layout=self.delete_text, shared=True) 44 | self.subscriber_select = Kdropdown(value=None, placeholder="Select subscriber...") 45 | self.subscriber_select.append(self.delete_button) 46 | 47 | # Display status 48 | self.status = Ktext(style={"control_width": 12}) 49 | 50 | layout = [self.token_text, self.remove_token, self.subscriber_select, self.delete_subscriber_yesno, self.status] 51 | 52 | dialog = Kdialog(title=[Kritter.icon("commenting"), "Texting Configuration"], layout=layout) 53 | 54 | self.layout = KsideMenuItem("Texting", dialog, "commenting") 55 | 56 | @dialog.callback_view() 57 | def func(open): 58 | if open: 59 | return self.update_state() # Entering -- update GUI state. 60 | 61 | # text_visor will call us when a user subscribes or unsubscribes, so we can update 62 | # the GUI accordingly. 63 | @self.text_visor.callback_subscribe() 64 | def func(): 65 | self.kapp.push_mods(self.update_state()) # update GUI state. 66 | 67 | @self.submit_token.callback(self.token_text.state_value()) 68 | def func(token): 69 | if not callback_context.client.authentication&pmask: 70 | return 71 | self.kapp.push_mods(self.submit_token.out_spinner_disp(True)) 72 | mods = self.submit_token.out_spinner_disp(False) 73 | try: 74 | self.text_visor.text_client.set_token(token) 75 | except Exception as e: 76 | return mods + self.status.out_value(f"There has been an error: {e}") 77 | return mods + self.update_state() 78 | 79 | @self.remove_token.callback() 80 | def func(): 81 | if not callback_context.client.authentication&pmask: 82 | return 83 | try: 84 | self.text_visor.text_client.remove_token() 85 | except Exception as e: 86 | return self.status.out_value(f"There has been an error: {e}") 87 | return self.update_state() 88 | 89 | @self.subscriber_select.callback() 90 | def func(selection): 91 | self.subscriber_selection = selection 92 | disabled = not bool(selection) 93 | return self.delete_button.out_disabled(disabled) 94 | 95 | @self.delete_button.callback() 96 | def func(): 97 | return self.delete_text.out_value(f'Are you sure you want to delete "{self.subscriber_selection}" subscriber?') + self.delete_subscriber_yesno.out_open(True) 98 | 99 | @self.test.callback() 100 | def func(): 101 | self.text_visor.send("This is a test. Thank you for your cooperation.") 102 | 103 | @self.delete_subscriber_yesno.callback_response() 104 | def func(val): 105 | # remove subscriber from recipient list where user's name is key 106 | if val: 107 | # find id associated with username 108 | userid = [id_ for id_, subscriber in self.text_visor.config['subscribers'].items() if subscriber['name']==self.subscriber_selection] 109 | userid = userid[0] # unwrap id 110 | del self.text_visor.config['subscribers'][userid] # delete key from subscribers 111 | self.text_visor.config.save() # save list to file 112 | self.kapp.push_mods(self.subscriber_select.out_value('')) # clear output 113 | return self.update_state() 114 | 115 | 116 | def update_state(self): 117 | # update subscriber list 118 | if self.text_visor.text_client.running(): # Running is the same as having a token... 119 | subscribers = [subscriber['name'] for subscriber in self.text_visor.config['subscribers'].values()] 120 | return self.token_text.out_disp(False) + self.submit_token.out_disp(False) + self.remove_token.out_disp(True) + self.status.out_value("Connected!") + self.subscriber_select.out_disp(True) + self.delete_button.out_disp(True) + self.subscriber_select.out_options(subscribers) + self.test.out_disp(bool(self.text_visor.config['subscribers'])) 121 | else: # ... and not running, no token 122 | return self.token_text.out_disp(True) + self.submit_token.out_disp(True) + self.remove_token.out_disp(False) + self.token_text.out_value("") + self.status.out_value(["Enter Vizy Bot Token to ", dcc.Link("connect", target="_blank", href=HELP_URL), "."]) + self.subscriber_select.out_disp(False) + self.delete_button.out_disp(False) + self.test.out_disp(False) 123 | 124 | def close(self): 125 | self.text_visor.close() 126 | -------------------------------------------------------------------------------- /src/vizy/timedialog.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Vizy 3 | # 4 | # All Vizy source code is provided under the terms of the 5 | # GNU General Public License v2 (http://www.gnu.org/licenses/gpl-2.0.html). 6 | # Those wishing to use Vizy source code, software and/or 7 | # technologies under different licensing terms should contact us at 8 | # support@charmedlabs.com. 9 | # 10 | 11 | import os 12 | import time 13 | from threading import Thread 14 | import dash_html_components as html 15 | import dash_bootstrap_components as dbc 16 | from dash_devices.dependencies import Output 17 | from dash_devices import callback_context 18 | import vizy.vizypowerboard as vpb 19 | from datetime import datetime 20 | from kritter import Kritter, Ktext, Kdialog, Kdropdown, Kbutton, KsideMenuItem, KokDialog 21 | 22 | SYNC_MESSAGE = [html.P(""" 23 | The system time synchronizer (systemd-timesyncd) retrieves an accurate time/date from a 24 | public Internet server and sets it automatically. Your Vizy is currently able to contact the server 25 | and accurately set the time (sweet!) The "Time" dialog is intended to be used when your Vizy 26 | is not able to make contact with the server. Note, you will not be able to set the time as long as the 27 | synchronizer is able to make contact (the time you set will be overwritten.)"""), 28 | html.P("""If the time is incorrect, the timezone setting may be wrong. Set the 29 | timezone by running "sudo raspi-config" and selecting "Localisation Options".""")] 30 | 31 | class TimeDialog: 32 | 33 | def __init__(self, kapp, pmask): 34 | self.kapp = kapp 35 | self.run = 0 36 | self.thread = None 37 | self.mtime = 0 38 | 39 | style = {"label_width": 4, "control_width": 4} 40 | self.months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"] 41 | self.days = [str(i) for i in range(1, 32)] 42 | self.years = [str(i) for i in range(2021, 2030)] 43 | self.hours = [str(i) for i in range(1, 24)] 44 | self.minutes = [str(i) for i in range(0, 60)] 45 | 46 | self.time_c = Ktext(name="Time", style={"label_width": 4, "control_width": 6}) 47 | self.month_c = Kdropdown(name='Month', options=self.months, style=style) 48 | self.day_c = Kdropdown(name='Day', options=self.days, style=style) 49 | self.year_c = Kdropdown(name='Year', options=self.years, style=style) 50 | self.hour_c = Kdropdown(name='Hour', options=self.hours, style=style) 51 | self.minute_c = Kdropdown(name='Minute', options=self.minutes, style=style) 52 | self.set = Kbutton(name=[Kritter.icon("clock-o"), "Set"]) 53 | self.ok = KokDialog(layout=SYNC_MESSAGE) 54 | layout = [self.time_c, self.month_c, self.day_c, self.year_c, self.hour_c, self.minute_c, self.ok] 55 | dialog = Kdialog(title=[Kritter.icon("clock-o"), "Clock"], left_footer=self.set, layout=layout) 56 | self.layout = KsideMenuItem("Clock", dialog, "clock-o") 57 | 58 | @dialog.callback_view() 59 | def func(open): 60 | if open: 61 | self.run += 1 62 | if self.run==1: 63 | self.thread = Thread(target=self.update_thread) 64 | self.thread.start() 65 | clock = datetime.now() 66 | return self.month_c.out_value(self.months[clock.month-1]) + self.day_c.out_value(str(clock.day)) + self.year_c.out_value(str(clock.year)) + self.hour_c.out_value(str(clock.hour)) + self.minute_c.out_value(str(clock.minute)) 67 | elif self.run>0: # Stale dialogs in browser can result in negative counts. 68 | self.run -= 1 69 | 70 | @self.set.callback(self.month_c.state_value() + self.day_c.state_value() + self.year_c.state_value() + self.hour_c.state_value() + self.minute_c.state_value()) 71 | def func(month, day, year, hour, minute): 72 | clock = datetime(int(year), self.months.index(month)+1, int(day), int(hour), int(minute)) 73 | self.kapp.power_board.rtc(clock) 74 | self.kapp.power_board.rtc_set_system_datetime(clock) 75 | return self.update() 76 | 77 | 78 | def update_thread(self): 79 | while(self.run): 80 | mods = self.update() 81 | try: 82 | mtime = os.path.getmtime("/run/systemd/timesync/synchronized") 83 | # Look for change in mtime, which indicates an update 84 | if self.mtime and self.mtime!=mtime: 85 | mods += self.ok.out_open(True) 86 | self.mtime = mtime 87 | except: 88 | pass 89 | self.kapp.push_mods(mods) 90 | time.sleep(1) 91 | 92 | def update(self): 93 | clock = datetime.now() 94 | return self.time_c.out_value(clock.strftime('%B %d, %Y %H:%M:%S')) 95 | 96 | def close(self): 97 | self.run = 0 98 | -------------------------------------------------------------------------------- /src/vizy/updatedialog.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Vizy 3 | # 4 | # All Vizy source code is provided under the terms of the 5 | # GNU General Public License v2 (http://www.gnu.org/licenses/gpl-2.0.html). 6 | # Those wishing to use Vizy source code, software and/or 7 | # technologies under different licensing terms should contact us at 8 | # support@charmedlabs.com. 9 | # 10 | 11 | import os 12 | import json 13 | import subprocess 14 | import base64 15 | from vizy import __version__, SCRIPTSDIR_NAME 16 | from dash_devices.dependencies import Input, Output, State 17 | from dash_devices import callback_context 18 | import dash_bootstrap_components as dbc 19 | import dash_html_components as html 20 | import dash_core_components as dcc 21 | from kritter import Kritter, KsideMenuItem, Kdialog, Kbutton 22 | from urllib.request import Request, urlopen 23 | from distutils.version import LooseVersion 24 | 25 | LATEST_SOFTWARE = "_latest.json" 26 | INSTALL_UPDATE_SCRIPT = "install_update" 27 | 28 | def get_latest(config, tries=3): 29 | for i in range(tries): 30 | try: 31 | url = os.path.join(config['software']['update server'], config['software']['channel']+LATEST_SOFTWARE) 32 | request = Request(url, headers={'User-Agent': 'Mozilla/5.0'}) 33 | latest = urlopen(request).read() 34 | latest = json.loads(latest) 35 | return latest, LooseVersion(latest['version']) > LooseVersion(__version__) 36 | except Exception as _e: 37 | e = _e 38 | raise e 39 | 40 | class UpdateDialog: 41 | 42 | # Note, we removed drag-drop file functionality, because as the build packages 43 | # became bigger (20MB), it became unreliable, so it has been removed for now. 44 | # There are size limits in the Quart configuration, but this doesn't seem to 45 | # be the whole story. 46 | 47 | def __init__(self, kapp, exit_app, pmask): 48 | self.kapp = kapp 49 | 50 | check_button = Kbutton(name=[Kritter.icon("cloud-download"), "Check for updates at vizycam.com"], spinner=True) 51 | check_response = html.Div(id=Kritter.new_id()) 52 | install_button = Kbutton(name=[Kritter.icon("angle-double-down"), "Install"], spinner=True) 53 | @install_button.callback() 54 | def func(): 55 | # Block unauthorized attempts 56 | if not callback_context.client.authentication&pmask: 57 | return 58 | # Start spinner 59 | self.kapp.push_mods(install_button.out_spinner_disp(True)) 60 | # Kill app 61 | exit_app() 62 | # Close dialog, start install/open install dialog. Note, execterm.exec takes a few seconds to 63 | # run because it waits, so this func will take a few more seconds to return the result. 64 | return self.update_dialog.out_open(False) + install_button.out_spinner_disp(False) + self.kapp.execterm.exec(command=f"python3 {os.path.join(self.kapp.homedir, SCRIPTSDIR_NAME, INSTALL_UPDATE_SCRIPT)}", size="lg", backdrop="static", close_button=False, logfile=os.path.join(self.kapp.homedir, SCRIPTSDIR_NAME, "install.log")) 65 | 66 | update_url = dbc.Input(id=Kritter.new_id(), placeholder="Copy URL of Vizy software package here and press Install.", type="text") 67 | install_button2 = Kbutton(name=[Kritter.icon("angle-double-down"), "Install"], spinner=True) 68 | 69 | update_layout = [ 70 | html.Div("You are currently running Vizy version "+__version__+".", style={"padding-bottom": "10px"}), 71 | check_button, check_response, 72 | html.Div("--OR--",style={"padding": "10px 0px 10px 0px"}), 73 | html.Div([update_url, install_button2]), 74 | ] 75 | 76 | self.update_dialog = Kdialog(title=[Kritter.icon("chevron-circle-up"), "Update your Vizy software and firmware"], layout=update_layout, kapp=self.kapp, shared=True) 77 | 78 | @check_button.callback() 79 | def func(): 80 | self.kapp.push_mods(check_button.out_spinner_disp(True) + [Output(check_response.id, "children", "")]) 81 | try: 82 | latest, newer = get_latest(self.kapp.vizy_config) 83 | if newer: 84 | check_layout = [html.Div("Version "+latest['version']+" is available."), install_button.layout] 85 | return [Output(check_response.id, "children", check_layout)] + check_button.out_spinner_disp(False) 86 | except Exception as e: 87 | return [Output(check_response.id, "children", "Error: " + str(e))] + check_button.out_spinner_disp(False) 88 | return [Output(check_response.id, "children", "You are running the latest version.")] + check_button.out_spinner_disp(False) 89 | 90 | @install_button2.callback([State(update_url.id, 'value')]) 91 | def func(url): 92 | # Block unauthorized attempts 93 | if not callback_context.client.authentication&pmask or not url: 94 | return 95 | if not url.startswith("http://") and not url.startswith("https://"): 96 | url = "http://" + url 97 | self.kapp.push_mods(install_button2.out_spinner_disp(True)) 98 | # Kill app 99 | exit_app() 100 | return self.update_dialog.out_open(False) + install_button2.out_spinner_disp(False) + self.kapp.execterm.exec(command=f"python3 {os.path.join(self.kapp.homedir, SCRIPTSDIR_NAME, INSTALL_UPDATE_SCRIPT)} {url}", size="lg", backdrop="static", close_button=False, logfile=os.path.join(self.kapp.homedir, SCRIPTSDIR_NAME, "install.log")) 101 | 102 | @self.update_dialog.callback_view() 103 | def func(enable): 104 | if not enable: 105 | return check_button.out_spinner_disp(False) + [Output(check_response.id, "children", None), Output(update_url.id, 'value', None)] 106 | 107 | self.layout = KsideMenuItem("Update", self.update_dialog, "chevron-circle-up", kapp=self.kapp) 108 | 109 | -------------------------------------------------------------------------------- /src/vizy/userdialog.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Vizy 3 | # 4 | # All Vizy source code is provided under the terms of the 5 | # GNU General Public License v2 (http://www.gnu.org/licenses/gpl-2.0.html). 6 | # Those wishing to use Vizy source code, software and/or 7 | # technologies under different licensing terms should contact us at 8 | # support@charmedlabs.com. 9 | # 10 | 11 | import os 12 | import time 13 | import dash_html_components as html 14 | from kritter import Kritter, KtextBox, Ktext, Kdropdown, Kbutton, Kdialog, KsideMenuItem, PMASK_MAX, PMASK_MIN 15 | from dash_devices.dependencies import Input, Output 16 | from dash_devices import callback_context 17 | import dash_bootstrap_components as dbc 18 | 19 | DUMMY_PASSWORD = "98446753" 20 | CHANGE = "Change" 21 | ADD = "Add" 22 | REMOVE = "Remove" 23 | ACTIONS = [CHANGE, ADD, REMOVE] 24 | MIN_USERNAME_LENGTH = 4 25 | MIN_PASSWORD_LENGTH = 4 26 | 27 | class UserDialog: 28 | 29 | def __init__(self, kapp, pmask, types=None): 30 | if os.geteuid()!=0: 31 | raise RuntimeError("You need to run with root permissions (psst, use sudo).") 32 | 33 | if types is None: 34 | from .vizyvisor import PMASK_APPS, PMASK_CONSOLE, PMASK_SYSTEM 35 | self.types = { 36 | "Guest": PMASK_MIN, 37 | "User": PMASK_APPS + PMASK_CONSOLE + PMASK_SYSTEM, 38 | "Admin": PMASK_MAX 39 | } 40 | else: 41 | self.types = types 42 | 43 | style = {"label_width": 4, "control_width": 5} 44 | self.kapp = kapp 45 | self.action_c = Kdropdown(name='Action', options=ACTIONS, style=style, service=None) 46 | self.usernames_c = Kdropdown(name='Username', style=style, service=None) 47 | self.username_c = KtextBox(name="Username", style=style, service=None) 48 | self.type_c = Kdropdown(name='Type', options=[k for k, v in self.types.items()], style=style, service=None) 49 | self.password_c = KtextBox(name="Password", type="password", style=style, service=None) 50 | self.c_password_c = KtextBox(name="Confirm password", type="password", style=style, service=None) 51 | self.a_password_c = KtextBox(name="Admin password", type="password", style=style, service=None) 52 | self.status_c = dbc.PopoverBody(id=Kritter.new_id()) 53 | self.save = Kbutton(name=[Kritter.icon("angle-double-down"), "Save"], service=None) 54 | self.po = dbc.Popover(self.status_c, id=Kritter.new_id(), is_open=False, target=self.save.id) 55 | 56 | layout = [self.action_c, self.usernames_c, self.username_c, self.type_c, self.password_c, self.c_password_c, self.a_password_c, self.po] 57 | 58 | dialog = Kdialog(title=[Kritter.icon("user"), "User Configuration"], left_footer=self.save, layout=layout) 59 | self.layout = KsideMenuItem("Users", dialog, "user") 60 | 61 | @dialog.callback_view() 62 | def func(open): 63 | if open: 64 | return self.action_c.out_value(CHANGE) 65 | else: 66 | return self.out_status(None) 67 | 68 | @self.action_c.callback() 69 | def func(action): 70 | return self.update(action) 71 | 72 | @self.usernames_c.callback(self.action_c.state_value()) 73 | def func(username, action): 74 | if username is None: 75 | return 76 | p = self.kapp.users.config['users'][username]['permissions'] 77 | for name, val in self.types.items(): 78 | if p==val: 79 | break 80 | 81 | mods = self.out_status(None) + self.a_password_c.out_disp(True) + self.a_password_c.out_value("") 82 | if action==CHANGE: 83 | # We don't know the password or password length so we insert a dummy password to indicate 84 | # that there's a password. 85 | mods += self.type_c.out_disp(True) + self.type_c.out_value(name) + self.password_c.out_disp(True) + self.password_c.out_value(DUMMY_PASSWORD) + self.c_password_c.out_disp(True) + self.c_password_c.out_value(DUMMY_PASSWORD) 86 | return mods 87 | 88 | @self.username_c.callback() 89 | def func(username): 90 | if len(username)>=MIN_USERNAME_LENGTH: 91 | return self.out_status(None) + self.type_c.out_disp(True) + self.password_c.out_disp(True) + self.a_password_c.out_disp(True) + self.type_c.out_value(None) + self.password_c.out_value("") + self.c_password_c.out_disp(True) + self.c_password_c.out_value("") + self.a_password_c.out_value("") 92 | 93 | @self.save.callback(self.action_c.state_value() + self.username_c.state_value() + self.usernames_c.state_value() + self.type_c.state_value() + self.password_c.state_value() + self.c_password_c.state_value() + self.a_password_c.state_value()) 94 | def func(action, username, usernames, _type, password, c_password, a_password): 95 | if not self.kapp.users.authorize(callback_context.client.username, a_password): 96 | return self.out_status("Sorry, admin password is incorrect.") 97 | if action==ADD and username in self.kapp.users.config['users']: 98 | return self.out_status(f"Sorry, username {username} already exists.") 99 | if action==ADD and _type is None: 100 | return self.out_status("Sorry, you need to specify type.") 101 | if action==REMOVE and callback_context.client.username==usernames: 102 | return self.out_status("Sorry, you cannot remove yourself.") 103 | if action==CHANGE and _type!="Admin" and callback_context.client.username==usernames: 104 | return self.out_status("Sorry, you cannot remove your own admin privileges.") 105 | if action==CHANGE or action==ADD: 106 | if password!=c_password: 107 | return self.out_status("Sorry, passwords do not match.") 108 | if len(password)10: 69 | # BTW we're likely being attacked... 70 | self.auth_cache = {} 71 | 72 | # Verify password 73 | try: 74 | info = self.config['users'][username] 75 | if Users.verify_password(password, info['password']): 76 | res = info['permissions'] 77 | except: 78 | pass 79 | 80 | # Cache result regardless 81 | self.auth_cache[up] = res 82 | 83 | return res 84 | 85 | def add_change_user(self, username, permissions, password): 86 | if password is None: 87 | try: 88 | password = self.config['users'][username]["password"] 89 | except: 90 | password = Users.hash_password(username) 91 | else: 92 | password = Users.hash_password(password) 93 | 94 | self.config['users'].update(user(username, permissions, password)) 95 | self.save() 96 | 97 | def remove_user(self, username): 98 | try: 99 | del self.config['users'][username] 100 | self.save() 101 | except: 102 | pass 103 | 104 | @staticmethod 105 | def hash_password(password, salt=None): 106 | if salt is None: 107 | # Generate random salt to mix with password if it isn't supplied. 108 | salt = binascii.hexlify(os.urandom(8)).decode().upper() 109 | password += salt 110 | # Generating a secure and timely hash on a Raspberry Pi is difficult 111 | # (dklen=10000), but this should be fine. 112 | hashed = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 10000).hex().upper() 113 | return f"{hashed}:{salt}" 114 | 115 | @staticmethod 116 | def verify_password(password, hash_string): 117 | salt = hash_string.split(":")[1] 118 | return Users.hash_password(password, salt)==hash_string 119 | -------------------------------------------------------------------------------- /src/vizy/vizy.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Vizy 3 | # 4 | # All Vizy source code is provided under the terms of the 5 | # GNU General Public License v2 (http://www.gnu.org/licenses/gpl-2.0.html). 6 | # Those wishing to use Vizy source code, software and/or 7 | # technologies under different licensing terms should contact us at 8 | # support@charmedlabs.com. 9 | # 10 | 11 | import os 12 | from kritter import Kritter, ConfigFile, Klogin, MEDIA_DIR 13 | from .vizypowerboard import VizyPowerBoard 14 | from .users import Users 15 | 16 | BASE_DIR = os.path.dirname(os.path.realpath(__file__)) 17 | VIZY_HOME = "VIZY_HOME" 18 | ETCDIR_NAME = 'etc' 19 | APPSDIR_NAME = 'apps' 20 | EXAMPLESDIR_NAME = 'examples' 21 | SCRIPTSDIR_NAME = 'scripts' 22 | 23 | VIZY_STYLE = ''' 24 | .side-button { 25 | min-width: 160px; 26 | text-align: left; 27 | margin: 2px 2px 0px 2px; 28 | padding: 4px 0px 4px 10px; 29 | /*color: #ffffff; 30 | font: verdana; 31 | border-radius: 0; */ 32 | border: 0px; 33 | background-color: #909090; 34 | } 35 | 36 | .button-div { 37 | /* background-color: blue; */ 38 | margin: 0px; 39 | padding: 0px; 40 | } 41 | 42 | html, body, #react-entry-point, #_main { 43 | height: 100%; 44 | } 45 | ''' 46 | 47 | CONFIG_FILE = "vizy_main.json" 48 | DEFAULT_CONFIG = { 49 | "software": { 50 | "update server": "https://vizycam.com/sd", 51 | "channel": "vizy_main", 52 | "start-up app": None, 53 | "start-up example": "video", 54 | "maximum logins": 3 55 | }, 56 | "hardware": { 57 | "power board": { 58 | "PCB type": "rpi_main", 59 | "firmware type": "main" 60 | }, 61 | "camera": { 62 | "type": "Sony IMX477 12.3 megapixel", 63 | "IR-cut": "switchable", 64 | "version": "1.0" 65 | }, 66 | "coprocessor": None 67 | } 68 | } 69 | 70 | 71 | def dirs(num): 72 | homedir = os.getenv(VIZY_HOME) 73 | if homedir is None: 74 | raise RuntimeError("VIZY_HOME environment variable should be set to the directory where Vizy software is installed.") 75 | if not os.path.exists(homedir): 76 | raise RuntimeError("VIZY_HOME directory doesn't exist!") 77 | uid = os.stat(homedir).st_uid 78 | gid = os.stat(homedir).st_gid 79 | etcdir = os.path.join(homedir, ETCDIR_NAME) 80 | if not os.path.exists(etcdir): 81 | os.mkdir(etcdir) 82 | os.chown(etcdir, uid, gid) 83 | appsdir = os.path.join(homedir, APPSDIR_NAME) 84 | if not os.path.exists(appsdir): 85 | os.mkdir(appsdir) 86 | os.chown(appsdir, uid, gid) 87 | examplesdir = os.path.join(homedir, EXAMPLESDIR_NAME) 88 | if not os.path.exists(examplesdir): 89 | os.mkdir(examplesdir) 90 | os.chown(examplesdir, uid, gid) 91 | 92 | result = homedir, etcdir, appsdir, examplesdir 93 | return result[0:num] 94 | 95 | 96 | class VizyConfig(ConfigFile): 97 | 98 | def __init__(self, etcdir): 99 | config_filename = os.path.join(etcdir, CONFIG_FILE) 100 | super().__init__(config_filename, DEFAULT_CONFIG) 101 | 102 | 103 | class VizyLogin(Klogin): 104 | 105 | def __init__(self, kapp): 106 | super().__init__(kapp, os.path.join(BASE_DIR, "login"), kapp.users.config['secret']) 107 | 108 | # Override authorize function with ours 109 | self.authorize_func = kapp.users.authorize 110 | 111 | 112 | class Vizy(Kritter): 113 | def __init__(self): 114 | 115 | super().__init__() 116 | 117 | self.title = "Vizy" 118 | self.homedir, self.etcdir, self.appsdir, self.examplesdir = dirs(4) 119 | self.vizy_config = VizyConfig(self.etcdir) 120 | self.users = Users(self.etcdir) 121 | 122 | # Add our own media path 123 | self.media_path.insert(0, os.path.join(BASE_DIR, MEDIA_DIR)) 124 | 125 | # Instantiate power board 126 | self.power_board = VizyPowerBoard() 127 | self.uuid = self.power_board.uuid() 128 | 129 | # Create login 130 | self.login = VizyLogin(self) 131 | 132 | @property 133 | def style(self): 134 | return self.__style 135 | 136 | @style.setter 137 | def style(self, _style): 138 | self.__style = _style 139 | _style = VIZY_STYLE + _style 140 | Kritter.style.fset(self, _style) 141 | 142 | 143 | 144 | 145 | -------------------------------------------------------------------------------- /src/vizy/vizyvisor.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Vizy 3 | # 4 | # All Vizy source code is provided under the terms of the 5 | # GNU General Public License v2 (http://www.gnu.org/licenses/gpl-2.0.html). 6 | # Those wishing to use Vizy source code, software and/or 7 | # technologies under different licensing terms should contact us at 8 | # support@charmedlabs.com. 9 | # 10 | 11 | import os 12 | import quart 13 | import dash_bootstrap_components as dbc 14 | import kritter 15 | import dash_html_components as html 16 | from dash_devices.dependencies import Input, Output 17 | from kritter.kterm import Kterm 18 | from kritter.keditor import Keditor 19 | from .vizy import Vizy 20 | from .vizypowerboard import VizyPowerBoard 21 | from .aboutdialog import AboutDialog 22 | from .wifidialog import WifiDialog 23 | from .updatedialog import UpdateDialog 24 | from .appsdialog import AppsDialog 25 | from .userdialog import UserDialog 26 | from .systemdialog import SystemDialog 27 | from .rebootdialog import RebootDialog 28 | from .timedialog import TimeDialog 29 | from .gclouddialog import GcloudDialog 30 | from .remotedialog import RemoteDialog 31 | from .textingdialog import TextingDialog 32 | 33 | VIZY_URL = "https://vizycam.com" 34 | # Permission bits: note, higher order bits don't necessarily mean higher levels of permission. 35 | # The bits just need to be distinct. 36 | PMASK_GUEST = 1<<0 37 | PMASK_NETWORKING = 1<<1 38 | PMASK_APPS = 1<<2 39 | PMASK_TIME = 1<<3 40 | PMASK_SYSTEM = 1<<4 41 | PMASK_UPDATE = 1<<5 42 | PMASK_SHELL = 1<<7 43 | PMASK_PYTHON = 1<<7 44 | PMASK_CONSOLE = 1<<8 45 | PMASK_EDITOR = 1<<9 46 | PMASK_USER = 1<<10 47 | PMASK_POWER = 1<<11 48 | PMASK_REBOOT = 1<<12 49 | PMASK_GCLOUD = 1<<13 50 | PMASK_REMOTE = 1<<14 51 | PMASK_TEXTING = 1<<15 52 | 53 | BRIGHTNESS = 0x30 54 | 55 | 56 | class VizyVisor(Vizy): 57 | 58 | def __init__(self, user="pi"): 59 | super().__init__() 60 | self.user = user 61 | self.wifi_state = None 62 | 63 | # Set up side menu 64 | self.side_menu_entries = [] 65 | self.prog_name = dbc.NavbarBrand("Program", id="_prog_name", style={"padding": 0}) 66 | self.prog_link = html.A(self.prog_name, target="_blank", id="_prog_link", style={"display": "none"}) 67 | self.message = html.Span("Starting application...", id="_message") 68 | self.start_message = html.Div([self.message, dbc.Spinner(color="white", size='sm', spinner_style={"margin": "auto 0 auto 5px"})], id="_message_div", style={"color": "white", "width": "100%", "margin": "auto", "display": "block"}) 69 | self.side_div = html.Div([dbc.DropdownMenu(self.side_menu_entries, id="_dropdown", toggleClassName="fa fa-bars fa-lg bg-dark", caret=False, direction="left", style={"text-align": "right", "width": "100%", "margin": "auto"})]) 70 | self.navbar = dbc.Navbar(html.Div([html.A(html.Img(src="/media/vizy_eye.png", style={"height": "25px"}), href=VIZY_URL, target="_blank", style={"margin": "auto 5px auto 0"}), self.prog_link, self.start_message, self.side_div], style={"width": "100%", "display": "inherit"}), color="dark", dark=True) 71 | self.iframe = html.Iframe(id=kritter.Kritter.new_id(), src="", style={"width": "100%", "height": "100%", "display": "block", "border": "none"}) 72 | 73 | self.console_item = kritter.KsideMenuItem("App Console", "/console", "desktop", target="_blank") 74 | self.shell_item = kritter.KsideMenuItem("Shell", "/shell", "terminal", target="_blank") 75 | self.python_item = kritter.KsideMenuItem("Python", "/python", "product-hunt", target="_blank") 76 | self.editor_item = kritter.KsideMenuItem("Editor", "/editor", "edit", target="_blank") 77 | self.logout_item = kritter.KsideMenuItem("Logout", "/logout", "sign-out") 78 | self.execterm = kritter.ExecTerm(self) 79 | 80 | self.texting_client = kritter.TelegramClient(self.etcdir) 81 | self.textvisor = kritter.KtextVisor(self.texting_client, self.etcdir) 82 | 83 | self.texting_dialog = TextingDialog(self, self.textvisor, PMASK_TEXTING) 84 | self.apps_dialog = AppsDialog(self, PMASK_CONSOLE, PMASK_APPS) 85 | self.about_dialog = AboutDialog(self, PMASK_GUEST, PMASK_EDITOR) 86 | self.user_dialog = UserDialog(self, PMASK_USER) 87 | self.wifi_dialog = WifiDialog(self, PMASK_NETWORKING) 88 | self.time_dialog = TimeDialog(self, PMASK_TIME) 89 | self.system_dialog = SystemDialog(self, self.textvisor, PMASK_POWER) 90 | self.update_dialog = UpdateDialog(self, self.apps_dialog.exit_app, PMASK_UPDATE) 91 | self.reboot_dialog = RebootDialog(self, PMASK_REBOOT) 92 | self.gcloud_dialog = GcloudDialog(self, PMASK_GCLOUD) 93 | self.remote_dialog = RemoteDialog(self, self.textvisor, PMASK_REMOTE) 94 | 95 | side_menu_items = [ 96 | self.about_dialog.layout, 97 | self.apps_dialog.layout, 98 | self.console_item, 99 | self.wifi_dialog.layout, 100 | self.time_dialog.layout, 101 | self.system_dialog.layout, 102 | self.shell_item, 103 | self.python_item, 104 | self.editor_item, 105 | self.user_dialog.layout, 106 | self.gcloud_dialog.layout, 107 | self.remote_dialog.layout, 108 | self.texting_dialog.layout, 109 | self.update_dialog.layout, 110 | self.logout_item, 111 | self.reboot_dialog.layout] 112 | 113 | # Add dialog layouts to main layout 114 | for i in side_menu_items: 115 | self.side_menu_entries.append(i.layout) 116 | if i.dialog is not None: 117 | # Add dialog to layout 118 | self.side_div.children.append(i.dialog.layout) 119 | # Add execterm dialog to layout 120 | self.side_div.children.append(self.execterm.layout) 121 | 122 | # Put iframe in a flex-box that can grow to occupy available space in viewport. 123 | self.layout = html.Div([self.navbar, html.Div(self.iframe, style={"flex-grow": "1"})], style={"display": "flex", "flex-direction": "column", "height": "100%"}) 124 | 125 | # We're running with root privileges, and we don't want the shell and 126 | # python to run with root privileges also. 127 | # We also want to change to the Vizy home directory. 128 | self.shell = Kterm(f'cd "{self.homedir}"; sudo -E -u {self.user} bash', name="Shell", protect=self.login.protect(PMASK_SHELL)) 129 | self.python = Kterm(f'cd "{self.homedir}"; sudo -E -u {self.user} python3', name="Python", protect=self.login.protect(PMASK_PYTHON)) 130 | self.editor = Keditor(path=self.homedir, settings_file=os.path.join(self.etcdir, "editor_settings.json"), protect=self.login.protect(PMASK_EDITOR)) 131 | 132 | self.server.register_blueprint(self.shell.server, url_prefix="/shell") 133 | self.server.register_blueprint(self.python.server, url_prefix="/python") 134 | self.server.register_blueprint(self.editor.server, url_prefix="/editor") 135 | app = kritter.Proxy(f"http://localhost:{kritter.PORT}") 136 | self.server.register_blueprint(app.server, url_prefix="/app") 137 | 138 | @self.callback_connect 139 | def func(client, connect): 140 | self.indicate() 141 | if connect: 142 | # Deal with permissions for a given user 143 | def hide(item): 144 | return [Output(item.layout.id, "style", {"display": "none"})] 145 | mods = [] 146 | if not client.authentication&PMASK_APPS: 147 | mods += hide(self.apps_dialog.layout) 148 | if not client.authentication&PMASK_NETWORKING: 149 | mods += hide(self.wifi_dialog.layout) 150 | if not client.authentication&PMASK_SHELL: 151 | mods += hide(self.shell_item) 152 | if not client.authentication&PMASK_PYTHON: 153 | mods += hide(self.python_item) 154 | if not client.authentication&PMASK_CONSOLE: 155 | mods += hide(self.console_item) 156 | if not client.authentication&PMASK_EDITOR: 157 | mods += hide(self.editor_item) 158 | if not client.authentication&PMASK_UPDATE: 159 | mods += hide(self.update_dialog.layout) 160 | if not client.authentication&PMASK_USER: 161 | mods += hide(self.user_dialog.layout) 162 | if not client.authentication&PMASK_REBOOT: 163 | mods += hide(self.reboot_dialog.layout) 164 | if not client.authentication&PMASK_TIME: 165 | mods += hide(self.time_dialog.layout) 166 | if not client.authentication&PMASK_SYSTEM: 167 | mods += hide(self.system_dialog.layout) 168 | if not client.authentication&PMASK_GCLOUD: 169 | mods += hide(self.gcloud_dialog.layout) 170 | if not client.authentication&PMASK_REMOTE: 171 | mods += hide(self.remote_dialog.layout) 172 | if not client.authentication&PMASK_TEXTING: 173 | mods += hide(self.texting_dialog.layout) 174 | 175 | # Put user's name next to the logout selection 176 | children = self.logout_item.layout.children 177 | children[1] = f"Logout ({client.username})" 178 | mods += [Output(self.logout_item.layout.id, "children", children)] 179 | 180 | return mods 181 | 182 | # If we're being accessed through an SSH tunnel, the client's browser may be requesting 183 | # through http or https. Vizy always uses http, which is reflected in the headers, 184 | # so when you have an iframe with relative url, the browser will use http even though 185 | # the other side of the tunnel is https. Looking at the forwarded protocol 186 | # (X-Forward-Proto) we can determine if the other side of the tunnel is https 187 | # and insert an upgrade CSP tag so that the secure things happen. 188 | # This is overriden from Dash. 189 | def interpolate_index(self, *args, **kwargs): 190 | index = super().interpolate_index(*args, **kwargs) 191 | match = "" 192 | i = len(match) 193 | tag = '\n' 194 | try: 195 | if quart.request.headers['X-Forwarded-Proto']=='https': 196 | i += index.find(match) 197 | return index[:i] + tag + index[i:] 198 | except: 199 | pass 200 | return index 201 | 202 | def out_main_src(self, src): 203 | return [Output(self.iframe.id, "src", src)] 204 | 205 | def out_start_message(self, message): 206 | return [Output(self.message.id, "children", message), Output(self.start_message.id, "style", {"color": "white", "width": "100%", "margin": "auto", "display": "block"}), Output(self.prog_link.id, "style", {"display": "none"})] 207 | 208 | def out_set_program(self, prog): 209 | url = VIZY_URL if prog['url'] is None else prog['url'] 210 | return [Output(self.start_message.id, "style", {"display": "none"}), Output(self.prog_name.id, "children", prog['name']), Output(self.prog_link.id, "href", url), Output(self.prog_link.id, "style", {"margin": "auto auto auto 0", "display": "block"})] + self.about_dialog.out_update(prog) 211 | 212 | def indicate(self, what=""): 213 | what = what.upper() 214 | 215 | # Save wifi state 216 | if what=="AP_CREATED" or what=="WIFI_CONNECTED": 217 | self.wifi_state = what 218 | 219 | if what=="VIZY_EXITING": 220 | self.power_board.led_background(BRIGHTNESS//2, BRIGHTNESS//2, 0) # back to yellow 221 | elif what=="OFF": 222 | self.power_board.led() 223 | elif what=="WAITING": 224 | self.power_board.led_unicorn(8) 225 | elif what=="ERROR": 226 | self.power_board.buzzer(250, 100, 30, 2) # double boop 227 | elif what=="OK": 228 | self.power_board.buzzer(2000, 250) # single beep 229 | elif what=="VIZY_RUNNING": 230 | self.power_board.led_background(0, BRIGHTNESS, 0) # green 231 | elif self.clients: 232 | self.power_board.led_background(BRIGHTNESS//2, 0, BRIGHTNESS//2) # magenta 233 | elif what=="AP_CREATED": 234 | self.power_board.led_background(0, BRIGHTNESS//2, BRIGHTNESS//2) # cyan 235 | elif what=="WIFI_CONNECTED": 236 | self.power_board.led_background(0, 0, BRIGHTNESS) # blue 237 | elif self.wifi_state is None: 238 | self.indicate("VIZY_RUNNING") 239 | else: 240 | self.indicate(self.wifi_state) 241 | 242 | def run(self): 243 | super().run() 244 | # Clean up... 245 | self.system_dialog.close() 246 | self.apps_dialog.close() 247 | self.reboot_dialog.close() 248 | self.time_dialog.close() 249 | self.remote_dialog.close() 250 | self.texting_dialog.close() 251 | self.textvisor.close() 252 | self.texting_client.close() 253 | # Show that we've exited. 254 | self.indicate("VIZY_EXITING") 255 | -------------------------------------------------------------------------------- /src/vizy/wificonnection.py: -------------------------------------------------------------------------------- 1 | # 2 | # This file is part of Vizy 3 | # 4 | # All Vizy source code is provided under the terms of the 5 | # GNU General Public License v2 (http://www.gnu.org/licenses/gpl-2.0.html). 6 | # Those wishing to use Vizy source code, software and/or 7 | # technologies under different licensing terms should contact us at 8 | # support@charmedlabs.com. 9 | # 10 | 11 | import os 12 | import NetworkManager 13 | import uuid 14 | import time 15 | from dbus.mainloop.glib import DBusGMainLoop 16 | 17 | DBusGMainLoop(set_as_default=True) 18 | 19 | WIFI_SSID = 1 20 | WIFI_AP = 2 21 | 22 | def get_wifi_device(): 23 | devs = list([ 24 | dev for dev in NetworkManager.NetworkManager.GetDevices() 25 | if dev.DeviceType == NetworkManager.NM_DEVICE_TYPE_WIFI 26 | ]) 27 | assert(len(devs) == 1) 28 | device = devs[0] 29 | return device 30 | 31 | def get_strength(ssid, device=None): 32 | if device is None: 33 | device = get_wifi_device() 34 | aps = [ap for ap in device.AccessPoints if ap.Ssid == ssid] 35 | strength = [ap.Strength for ap in aps] 36 | return max(strength) 37 | 38 | 39 | def get_active_connection(ssid): 40 | active = NetworkManager.NetworkManager.ActiveConnections 41 | active = [x for x in active if x.Connection.GetSettings()['connection']['id'] == ssid] 42 | assert(len(active) <= 1) 43 | if len(active)==1 and active[0].State==NetworkManager.NM_ACTIVE_CONNECTION_STATE_ACTIVATED: 44 | return active[0] 45 | else: 46 | return None 47 | 48 | 49 | class WifiConnection(object): 50 | 51 | def __init__(self, ssid, password, mode=WIFI_SSID): 52 | self.mode = mode 53 | self.ssid = ssid 54 | self.password = password 55 | 56 | def remove_old_connections(self): 57 | try: 58 | active = get_active_connection(self.ssid) 59 | if active: 60 | NetworkManager.NetworkManager.DeactivateConnection(active) 61 | self.waitForDisconnection(active) 62 | except: 63 | pass 64 | 65 | for connection in NetworkManager.Settings.ListConnections(): 66 | settings = connection.GetSettings() 67 | if settings['connection']['id'] == self.ssid: 68 | connection.Delete() 69 | 70 | def get_connection(self): 71 | if self.mode == WIFI_AP: 72 | return { 73 | 'connection': {'id': self.ssid, 74 | 'type': '802-11-wireless', 75 | 'uuid': str(uuid.uuid4())}, 76 | 'ipv4': {'method': 'shared'}, 77 | 'ipv6': {'method': 'ignore'}, 78 | '802-11-wireless-security': {'key-mgmt': 'wpa-psk', 'psk': self.password}, 79 | '802-11-wireless': {'mode': 'ap', 'ssid': self.ssid}, 80 | } 81 | else: 82 | return { 83 | 'connection': {'id': self.ssid, 84 | 'type': '802-11-wireless', 85 | 'uuid': str(uuid.uuid4())}, 86 | 'ipv4': {'method': 'auto'}, 87 | 'ipv6': {'method': 'auto'}, 88 | '802-11-wireless-security': { 89 | 'auth-alg': 'open', 90 | 'key-mgmt': 'wpa-psk', 91 | 'psk': self.password 92 | }, 93 | '802-11-wireless': {'mode': 'infrastructure', 'ssid': self.ssid}, 94 | } 95 | 96 | def activate(self): 97 | 98 | self.remove_old_connections() 99 | connection = self.get_connection() 100 | try: 101 | con = NetworkManager.Settings.AddConnection(connection) 102 | device = get_wifi_device() 103 | activeConnection = NetworkManager.NetworkManager.ActivateConnection( 104 | con, device, "/") 105 | self.waitForConnection(activeConnection) 106 | except: 107 | return None 108 | # Add MDNS multicast route -- only needs to be added for AP mode. 109 | if self.mode==WIFI_AP: 110 | os.system("ip route add 224.0.0.0/4 dev wlan0") 111 | return activeConnection 112 | 113 | def deactivate(self, activeConnection): 114 | NetworkManager.NetworkManager.DeactivateConnection(activeConnection) 115 | self.waitForDisconnection(activeConnection) 116 | 117 | def waitForConnection(self, conn): 118 | while conn.State != NetworkManager.NM_ACTIVE_CONNECTION_STATE_ACTIVATED: 119 | time.sleep(0.5) 120 | 121 | def waitForDisconnection(self, conn): 122 | try: 123 | while conn.State == NetworkManager.NM_ACTIVE_CONNECTION_STATE_ACTIVATED: 124 | time.sleep(0.5) 125 | except NetworkManager.ObjectVanished(_): 126 | pass 127 | -------------------------------------------------------------------------------- /sys/vizy-power-monitor.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Vizy power monitor 3 | After=sysinit.target 4 | 5 | [Service] 6 | Type=simple 7 | Restart=always 8 | EnvironmentFile=/etc/environment 9 | StandardOutput=journal+console 10 | StandardError=journal+console 11 | ExecStart=/bin/python3 -u "${VIZY_HOME}/scripts/vizy_power_monitor" 12 | TimeoutStopSec=5 13 | SendSIGHUP=yes 14 | SendSIGKILL=yes 15 | 16 | [Install] 17 | WantedBy=multi-user.target 18 | 19 | -------------------------------------------------------------------------------- /sys/vizy-power-off.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Vizy power off 3 | DefaultDependencies=no 4 | Conflicts=reboot.target 5 | Before=poweroff.target halt.target shutdown.target 6 | Requires=poweroff.target 7 | 8 | [Service] 9 | Type=oneshot 10 | EnvironmentFile=/etc/environment 11 | ExecStart=/bin/bash -c "${VIZY_HOME}/scripts/vizy_power_off" 12 | RemainAfterExit=yes 13 | 14 | [Install] 15 | WantedBy=shutdown.target -------------------------------------------------------------------------------- /sys/vizy-server.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Vizy server 3 | 4 | [Service] 5 | Type=simple 6 | Restart=always 7 | EnvironmentFile=/etc/environment 8 | StandardOutput=journal+console 9 | StandardError=journal+console 10 | ExecStart=/bin/python3 -u "${VIZY_HOME}/scripts/vizy_server" 11 | TimeoutStopSec=5 12 | SendSIGHUP=yes 13 | SendSIGKILL=yes 14 | LimitRTPRIO=99 15 | 16 | [Install] 17 | WantedBy=multi-user.target 18 | 19 | -------------------------------------------------------------------------------- /sys/vizy_io-0.1.3.fwe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmedlabs/vizy/59e443cf61150b3b4072c1817665757b1aad611b/sys/vizy_io-0.1.3.fwe --------------------------------------------------------------------------------