├── LICENSE ├── README.md ├── models ├── LED matrix v11.f3d ├── cover.stl └── grid.stl └── wled.py /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Ralf Vogler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LED-matrix 2 | 3 | 16x16 pixel RGB LED matrix with support for [WLED](https://github.com/Aircoookie/WLED) and text (digits only for now). 4 | 5 | Price for components was about 18€ (see [comment](https://www.mydealz.de/deals/divoom-pixoo-pixelart-display-16x16-nft-foto-frame-inkl-akku-1954933#comment-35482339)). 6 | 7 | Use WLED and its interfaces for controlling the light and showing effects. 8 | To show digits (which WLED can't), see `wled.py` which sends pixel information to WLED via UDP. 9 | 10 | Run a server taking commands via MQTT on `lights/wled-matrix` with `python3 wled.py mqtt`. 11 | Commands are `on, off, num 123, co2`. 12 | 13 | Grid and case were designed in Fusion 360: https://a360.co/36UBWL9 14 | 15 | You can 3D print the `.stl` files in `models`. 16 | Beware of warping (visible in top right corner below). 17 | 18 | ![image](https://user-images.githubusercontent.com/493741/156219889-854490f8-e715-45d4-9400-5dd8a94ac959.png) 19 | ![image](https://user-images.githubusercontent.com/493741/156219938-665f8553-356a-4c82-9fce-6b1e8f622a15.png) 20 | 21 | More well-documented projects are mentioned [here in german](https://www.mydealz.de/comments/permalink/36838747) and at https://github.com/2dom/PxMatrix#examples. 22 | 23 | ### TODO 24 | - [ ] endpoint for animations, .gif upload etc. 25 | - via [WLED: Scrolling Text Feature](https://github.com/Aircoookie/WLED/issues/1207#issuecomment-1193900656): 26 | Webserver to upload and display jpeg: [webserver_jpeg_ws2812.ino](https://github.com/datasith/Ai_Demos_ESP8266/blob/master/webserver_jpeg_ws2812/webserver_jpeg_ws2812.ino) 27 | - [JS Pixel Art Editor](https://eloquentjavascript.net/19_paint.html) 28 | 29 | ### Log 30 |
31 | Click to expand 32 | 33 | 01.03.22 Created this repo and [extracted commits](https://www.pixelite.co.nz/article/extracting-file-folder-from-git-repository-with-full-git-history/) from [smart-home](https://github.com/vogler/smart-home/search?q=wled&type=commits): 34 | ```console 35 | $ cd smart-home 36 | $ git log --pretty=email --patch-with-stat --reverse --full-index --binary -- audio-reactive-led-strip wled.py > ../patch 37 | $ cd ../LED-matrix 38 | $ git am < ../patch 39 | ``` 40 | 41 | 19.02.23 Was still commiting to `smart-home/wled.py` instead of here. Extracted new commits, deleted the file there and added this repo as a submodule. 42 | 43 | Also noticed that GitHub showed 'Mar 1, 2022' for all extracted commits. 44 | Reason was that AuthorDate was correct, but CommitDate was set to the time of amend operation. Normal `git log` shows AuthorDate, `git log --pretty=fuller` also shows CommitDate which is what GitHub uses. 45 | Fix was to use `git am --committer-date-is-author-date`. However, had to get rid of the commits with the wrong date first, and then redo: 46 | 47 | ``` 48 | $ cd ../smart-home 49 | $ git log --pretty=email --patch-with-stat --reverse --full-index --binary -- audio-reactive-led-strip wled.py > ../wled.patch 50 | $ # split it into wled1.patch (up to Nov 9 2021) and wled2.patch (from Nov 12 2022) 51 | $ cd ../LED-matrix 52 | $ git log --pretty=email --patch-with-stat --reverse --full-index --binary > ../led-matrix.patch 53 | $ # delete commits that are also in wled.patch from led-matrix.patch 54 | $ git reset b3d14b1f3fea1b708972e8da08000790efedad8c # go back to 'Initial commit' 55 | $ git am --committer-date-is-author-date < ../wled1.patch 56 | $ git am --committer-date-is-author-date < ../led-matrix.patch 57 | ``` 58 |
59 | -------------------------------------------------------------------------------- /models/LED matrix v11.f3d: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vogler/LED-matrix/062bb734140c4f850d49f2eed0a05063bdf8ff87/models/LED matrix v11.f3d -------------------------------------------------------------------------------- /models/cover.stl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vogler/LED-matrix/062bb734140c4f850d49f2eed0a05063bdf8ff87/models/cover.stl -------------------------------------------------------------------------------- /models/grid.stl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vogler/LED-matrix/062bb734140c4f850d49f2eed0a05063bdf8ff87/models/grid.stl -------------------------------------------------------------------------------- /wled.py: -------------------------------------------------------------------------------- 1 | # based on https://github.com/scottlawsonbc/audio-reactive-led-strip/blob/master/python/led.py 2 | # via https://kno.wled.ge/interfaces/udp-realtime/#setup-with-arls 3 | 4 | # inlined from config.py 5 | HOST = 'wled-matrix' 6 | UDP_PORT = 21324 7 | W = 16 # width pixels 8 | H = 16 # height pixels 9 | MQTT_BROKER = 'localhost' 10 | MQTT_TOPIC = 'lights/wled-matrix' 11 | MQTT_CO2_TOPIC = 'sensors/mh-z19b' 12 | MQTT_TH_TOPIC = 'sensors/bme280' 13 | 14 | import time 15 | import numpy as np 16 | import socket 17 | _sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 18 | 19 | pixels = np.full((H,W,3), 0, np.uint8) 20 | prev_pixels = np.copy(pixels) 21 | 22 | def update(): 23 | """Sends UDP packets to ESP8266 to update LED strip values 24 | 25 | The packet encoding scheme is: 26 | |i|r|g|b| 27 | where 28 | i (0 to 255): Index of LED to change (zero-based) 29 | r (0 to 255): Red value of LED 30 | g (0 to 255): Green value of LED 31 | b (0 to 255): Blue value of LED 32 | """ 33 | global pixels, prev_pixels 34 | idx = [(y,x) for y in range(H) for x in range(W) if (pixels[y,x] != prev_pixels[y,x]).any()] # indices where value changed 35 | MAX_PIXELS_PER_PACKET = 126 36 | n_packets = len(idx) // MAX_PIXELS_PER_PACKET + 1 37 | packets = np.array_split(idx, n_packets) 38 | try: 39 | for idx in packets: 40 | m = [] 41 | # packet header: https://kno.wled.ge/interfaces/udp-realtime/#udp-realtime 42 | m.append(1) # protocol: WARLS (WLED Audio-Reactive-Led-Strip) 43 | m.append(2) # wait 2s after the last received packet before returning to normal mode 44 | for (y,x) in idx: 45 | # zig-zag layout: 0 starts top left going down but then second column goes up 46 | index = x*H + (H-1-y if x%2 else y) 47 | m.append(index) 48 | m.extend(pixels[y,x]) # RGB values 49 | _sock.sendto(bytes(m), (HOST, UDP_PORT)) 50 | except Exception as e: 51 | print('update failed:', e) 52 | prev_pixels = np.copy(pixels) 53 | 54 | def clear(): 55 | global pixels 56 | pixels *= 0 # Turn all pixels off 57 | 58 | # Scrolls a red, green, and blue pixel across the LED matrix continuously 59 | def strand(): 60 | global pixels 61 | clear() 62 | pixels[0, 0] = [255, 0, 0] # red 63 | pixels[1, 0] = [0, 255, 0] # green 64 | pixels[2, 0] = [0, 0, 255] # blue 65 | print('Starting LED strand test') 66 | i = 0 67 | while True: 68 | pixels = np.roll(pixels, 1, 0 if i%17 else 1) 69 | i = (i+1) % 256 70 | update() 71 | time.sleep(.05) 72 | 73 | 74 | digits = {} 75 | digits[0] = [[1,1,1], 76 | [1,0,1], 77 | [1,0,1], 78 | [1,0,1], 79 | [1,1,1]] 80 | digits[1] = [[0,0,1], 81 | [0,1,1], 82 | [0,0,1], 83 | [0,0,1], 84 | [0,0,1]] 85 | digits[1] = [[0,1,0], 86 | [1,1,0], 87 | [0,1,0], 88 | [0,1,0], 89 | [1,1,1]] 90 | digits[2] = [[1,1,1], 91 | [0,0,1], 92 | [1,1,1], 93 | [1,0,0], 94 | [1,1,1]] 95 | digits[3] = [[1,1,1], 96 | [0,0,1], 97 | [1,1,1], 98 | [0,0,1], 99 | [1,1,1]] 100 | digits[4] = [[1,0,1], 101 | [1,0,1], 102 | [1,1,1], 103 | [0,0,1], 104 | [0,0,1]] 105 | digits[5] = [[1,1,1], 106 | [1,0,0], 107 | [1,1,1], 108 | [0,0,1], 109 | [1,1,1]] 110 | digits[6] = [[1,1,1], 111 | [1,0,0], 112 | [1,1,1], 113 | [1,0,1], 114 | [1,1,1]] 115 | digits[7] = [[1,1,1], 116 | [0,0,1], 117 | [0,1,0], 118 | [0,1,0], 119 | [0,1,0]] 120 | digits[8] = [[1,1,1], 121 | [1,0,1], 122 | [1,1,1], 123 | [1,0,1], 124 | [1,1,1]] 125 | digits[9] = [[1,1,1], 126 | [1,0,1], 127 | [1,1,1], 128 | [0,0,1], 129 | [1,1,1]] 130 | 131 | colors = { 132 | "black": [0, 0, 0], 133 | "red": [255, 0, 0], 134 | "yellow": [255, 255, 0], 135 | "lime": [0, 255, 0], 136 | "blue": [0, 0, 255], 137 | "cyan": [0, 255, 255], 138 | "magenta": [255, 0, 255], 139 | "white": [255, 255, 255], 140 | # all the 128 variations are the same as above, just a little bit less bright 141 | "gray": [128, 128, 128], 142 | "maroon": [128, 0, 0], 143 | "green": [0, 128, 0], 144 | "navy": [0, 0, 128], 145 | "olive": [128, 128, 0], 146 | "teal": [0, 128, 128], 147 | "purple": [128, 0, 128], 148 | # some inbetween colors 149 | "orange": [255, 165, 0], 150 | "coral": [255, 127, 80], # same as orange 151 | "forest": [34, 139, 34], 152 | "cadet": [95, 158, 160], 153 | "steel": [70, 130, 180], 154 | "cornflower": [100, 149, 237], 155 | "plum": [221, 160, 221], 156 | } 157 | 158 | # place RGB pixels p at position x, y 159 | def place(p, x, y): 160 | global pixels 161 | for py in range(len(p)): 162 | for px in range(len(p[0])): 163 | if p[py][px]: 164 | pixels[y+py, x+px] = p[py][px] 165 | 166 | # color a mask m (entries 0 or 1), bg is optional background fill color 167 | def color_mask(color, m, bg=None): 168 | o = {} 169 | for y in range(len(m)): 170 | o[y] = {} 171 | for x in range(len(m[0])): 172 | o[y][x] = color if m[y][x] == 1 else bg 173 | return o 174 | 175 | # example: place(color_mask(colors["red"], digits[4]), 1, 1) 176 | 177 | # show a number n at position x, y with spacing between digits and rotating colors 178 | # x=0 is ltr, x=-1 is rtl starting at x=15; default colors without the first (black) 179 | def show_number(n, x=-2, y=5, spacing=1, colors=list(colors.values())[1:], bg=colors['black']): 180 | ds = [int(c) for c in str(n)] 181 | dl = len(digits[0][0]) 182 | dw = dl + spacing 183 | if x < 0: 184 | x += W - dl+1 185 | dw *= -1 186 | ds.reverse() 187 | for i in range(len(ds)): 188 | color = colors[(len(ds)-1-i)%len(colors)] 189 | p = color_mask(color, digits[ds[i]], bg) 190 | place(p, x+i*dw, y) 191 | 192 | # https://kno.wled.ge/interfaces/mqtt/ subscribe to brightness changes (>0 is on): mosquitto_sub -t wled/matrix/g 193 | # https://kno.wled.ge/interfaces/json-api/ 194 | import requests 195 | # https://stackoverflow.com/questions/15431044/can-i-set-max-retries-for-requests-request 196 | from requests.adapters import HTTPAdapter, Retry 197 | s = requests.Session() 198 | retries = Retry(total=5, backoff_factor=0.2) 199 | s.mount('http://', HTTPAdapter(max_retries=retries)) 200 | 201 | def is_on(): 202 | try: 203 | return requests.get(f'http://{HOST}/json/state').json()['on'] 204 | except Exception as e: 205 | print('is_on failed:', e) 206 | return False 207 | 208 | def set_on(on): # doc says "t" should toggle, but does not work (also their curl example) -> only bool 209 | if type(on) is str: on = on == 'on' # on -> True | _ -> False 210 | try: 211 | requests.post(f'http://{HOST}/json/state', json = {'on': on}) 212 | except Exception as e: 213 | print('set_on failed:', e) 214 | 215 | def usage(): 216 | print('usage: python3 %s [cmd]' % sys.argv[0]) 217 | print('[cmd]:') 218 | print('\ton|off\tturn on/off') 219 | print('\tnum [n]\tshow number n in colors until killed') 220 | print('\tco2\tshow co2 level updated via MQTT in colors until killed') 221 | print('\tco2th\tshow co2 level + temperature + humidity - updated via MQTT in colors until killed') 222 | print('\tmqtt\tsubscribe to %s for the above commands (numeric payload for num)' % MQTT_TOPIC) 223 | quit(1) 224 | 225 | import paho.mqtt.client as mqtt 226 | import json 227 | from threading import Lock 228 | mutex = Lock() # protect pixels, otherwise we get races updating them 229 | is_showing = False 230 | data = dict() 231 | 232 | def show_co2x(): 233 | global data 234 | clear() # TODO only needed if the number of digits changes... 235 | if data['cmd'] == 'co2': 236 | show_number(data['co2']) 237 | if data['cmd'] == 'co2th': 238 | if 'co2' in data: show_number(data['co2'], y=2) 239 | if 'temp' in data: show_number(round(data['temp']), y=9, x=0, colors=[colors['purple']]) 240 | if 'humi' in data: show_number(round(data['humi']), y=9, x=8, colors=[colors['teal']]) 241 | 242 | def on_message(client, userdata, msg): 243 | global data 244 | # print(msg.topic, str(msg.payload)) 245 | mutex.acquire() 246 | if msg.topic == MQTT_CO2_TOPIC: 247 | co2 = data['co2'] = json.loads(msg.payload)['co2'] 248 | print('co2:', co2) 249 | show_co2x() 250 | elif msg.topic == MQTT_TH_TOPIC: 251 | j = json.loads(msg.payload) 252 | t = data['temp'] = j['temperature'] 253 | h = data['humi'] = j['humidity'] 254 | print('temp:', t, 'humi:', round(h, 2)) 255 | show_co2x() 256 | elif msg.topic == MQTT_TOPIC: 257 | m = msg.payload.decode('utf-8') 258 | client.unsubscribe(MQTT_CO2_TOPIC) 259 | client.unsubscribe(MQTT_TH_TOPIC) 260 | print('MQTT cmd:', m) 261 | time.sleep(1) # TODO better solution 262 | clear() 263 | if m == '0': m = 'off' 264 | data['cmd'] = m 265 | if m in ['on', 'off']: 266 | set_on(m) 267 | elif m.isnumeric(): 268 | set_on(True) 269 | show_number(int(m)) 270 | elif m == 'co2': 271 | set_on(True) 272 | client.subscribe(MQTT_CO2_TOPIC) 273 | elif m == 'co2th': 274 | set_on(True) 275 | client.subscribe(MQTT_CO2_TOPIC) 276 | client.subscribe(MQTT_TH_TOPIC) 277 | else: 278 | print('MQTT: unhandled payload', m) 279 | mutex.release() 280 | 281 | client = mqtt.Client() 282 | client.on_connect = lambda client, userdata, flags, rc: print("Connected to MQTT (code %d) " % rc) 283 | client.on_message = on_message 284 | 285 | if __name__ == '__main__': 286 | import sys 287 | argc = len(sys.argv) 288 | if argc < 2: usage() 289 | cmd = data['cmd'] = sys.argv[1].lower() 290 | was_on = is_on() 291 | print('was_on', was_on) 292 | should_be_on = cmd != 'off' and cmd != 'mqtt' 293 | if was_on != should_be_on: set_on(should_be_on) 294 | try: 295 | if cmd in ['on', 'off']: 296 | pass 297 | elif cmd == 'num': 298 | if argc != 3: usage() 299 | num = int(sys.argv[2]) 300 | show_number(num) 301 | while True: 302 | update() 303 | elif cmd in ['co2', 'co2th', 'mqtt']: 304 | client.connect(MQTT_BROKER) 305 | if cmd == 'mqtt': 306 | client.subscribe(MQTT_TOPIC) 307 | elif cmd == 'co2': 308 | client.subscribe(MQTT_CO2_TOPIC) 309 | elif cmd == 'co2th': 310 | client.subscribe(MQTT_CO2_TOPIC) 311 | client.subscribe(MQTT_TH_TOPIC) 312 | # client.loop_forever() # blocks, but co2 comes only every 10s, w/o update() WLED goes back to normal mode 313 | client.loop_start() # starts a thread; could also use client.loop() below, but not as responsive. 314 | while True: 315 | mutex.acquire() 316 | update() 317 | mutex.release() 318 | time.sleep(1) 319 | else: 320 | usage() 321 | except KeyboardInterrupt: 322 | print('exit') 323 | finally: 324 | if not was_on and cmd != 'on' and cmd != 'off': 325 | clear() 326 | update() 327 | time.sleep(1) # give time to process last UDP packets, otherwise it does not turn off 328 | set_on(False) 329 | print('turned off again') 330 | --------------------------------------------------------------------------------