16 Commits

Author SHA1 Message Date
364ea9971b Veits tihedam rgbt lihtsalt 2022-04-07 19:23:23 +03:00
siinus
33109455c9 Set paths absolute 2021-05-03 19:05:27 +03:00
103e04a1b4 Merge pull request 'dotenv' (#7) from feature/dotenv into master
Reviewed-on: #7
2021-05-03 10:50:23 +00:00
siinus
86dd419080 Pointless dependency 2021-05-03 00:44:35 +03:00
siinus
0a5e1a3a5d Fix readme 2021-05-02 01:45:04 +03:00
siinus
74b6cbe7e3 Create .env for config 2021-05-02 01:37:38 +03:00
e6fb8ea528 Merge pull request 'Update 210319' (#6) from feature/tempo into master
Reviewed-on: #6
2021-03-19 21:16:35 +00:00
siinus
0d59414104 Add program selecting for status update 2021-03-19 23:08:10 +02:00
siinus
3fa0bc376f Replace program selector with radios, because chrome for android sucks 2021-03-19 22:10:42 +02:00
siinus
b3240631f6 Replace hexToRgb function to work with legacy mobile browsers 2021-03-19 20:46:51 +02:00
siinus
76759f986b Add tempo, live status 2021-03-19 19:00:41 +02:00
siinus
57559ea70a Some possibility to break the loop 2021-03-05 23:04:40 +02:00
siinus
1b3b23bc99 Add Gaynbow program 2021-03-05 22:49:27 +02:00
siinus
d135c8c24f Add second ticker program 2021-03-05 21:01:06 +02:00
siinus
bdcfdb9a9f Retitle some essential buttons 2021-03-05 19:33:42 +02:00
810fefe323 Add zone switching (#5)
Pixel calculation error

Add zone switching

Co-authored-by: siinus <pearu@siinus.com>
Reviewed-on: #5
2021-03-05 16:10:00 +00:00
16 changed files with 450 additions and 72 deletions

View File

@@ -8,20 +8,9 @@ Projekt Litsimaja - Lapikute tagatoa seintele programmeeritavad ARGB ribad
#### Running with emulation #### Running with emulation
This is mainly for testing, development. This is mainly for testing, development.
In ``lib/Litsimaja.py`` change the following: Create .env file:
``` ```cp .env.example .env```
# from lib.strip.TkinterStrip import TkinterStrip
def __init__(self): and see: ```USE_EMULATOR```
self._strip = PixelStrip(290, 18, 800000, 10, False, 255, 0, 4104)
```
to
```
from lib.strip.TkinterStrip import TkinterStrip
def __init__(self):
self._strip = TkinterStrip(290, 18, 800000, 10, False, 255, 0, 4104)
```
Now when you run the program, you will see a Tkinter window pop up with a rectangle simulating the LED strip. Now when you run the program, you will see a Tkinter window pop up with a rectangle simulating the LED strip.
Don't commit this change.

17
pyleds/.env.defaults Normal file
View File

@@ -0,0 +1,17 @@
ENVIRONMENT=prod
USE_EMULATOR=false
BIND_ADDR=127.0.0.1
BIND_PORT=8080
# Strip configuration
STRIP_PIXELS=290
STRIP_GPIO_PIN=18
STRIP_HZ=800000
STRIP_DMA=10
STRIP_INVERT=false
STRIP_BRIGHTNESS=255
STRIP_CHANNEL=0
STRIP_TYPE=4104
REGIONS=46,96,191,241
BPM_DEFAULT=60

2
pyleds/.env.example Normal file
View File

@@ -0,0 +1,2 @@
ENVIRONMENT=dev
USE_EMULATOR=true

1
pyleds/.gitignore vendored
View File

@@ -1 +1,2 @@
.env
litsimaja.log litsimaja.log

38
pyleds/lib/Config.py Normal file
View File

@@ -0,0 +1,38 @@
from dotenv import dotenv_values
import pathlib
def populate_values(load: {}):
conf = load
conf['IS_DEV']: bool = str.lower(load['ENVIRONMENT']) == 'dev'
conf['USE_EMULATOR']: bool = str.lower(load['USE_EMULATOR']) == 'true'
conf['BIND_PORT']: int = int(load['BIND_PORT'])
conf['STRIP_PIXELS']: int = int(load['STRIP_PIXELS'])
conf['STRIP_GPIO_PIN']: int = int(load['STRIP_GPIO_PIN'])
conf['STRIP_HZ']: int = int(load['STRIP_HZ'])
conf['STRIP_DMA']: int = int(load['STRIP_DMA'])
conf['STRIP_INVERT']: bool = str.lower(load['STRIP_INVERT']) == 'true'
conf['STRIP_BRIGHTNESS']: int = int(load['STRIP_BRIGHTNESS'])
conf['STRIP_CHANNEL']: int = int(load['STRIP_CHANNEL'])
conf['STRIP_TYPE']: int = int(load['STRIP_TYPE'])
conf['REGIONS']: [] = [int(x) for x in load['REGIONS'].split(',')]
conf['BPM_DEFAULT']: int = int(load['BPM_DEFAULT'])
return conf
class Config(object):
_config: {} = {}
def __init__(self):
rp = str(pathlib.Path(__file__).parent.parent.absolute())
load_conf = {
**dotenv_values(rp + "/.env.defaults"),
**dotenv_values(rp + "/.env"),
}
self._config = populate_values(load_conf)
def get(self, key: str):
return self._config[key]

View File

@@ -1,16 +1,30 @@
from rpi_ws281x import PixelStrip from lib.Config import Config
# from lib.strip.WindowStrip import WindowStrip
# from lib.strip.TkinterStrip import TkinterStrip
from lib.LoopSwitch import LoopSwitch from lib.LoopSwitch import LoopSwitch
from lib.Regions import Regions
from lib.Tempo import Tempo
class Litsimaja(object): class Litsimaja(object):
_loops: []
def __init__(self): def __init__(self):
self._strip = PixelStrip(290, 18, 800000, 10, False, 255, 0, 4104) self._config = Config()
if self.conf('USE_EMULATOR'):
module = __import__('lib.strip.TkinterStrip', None, None, ['TkinterStrip'])
class_name = 'TkinterStrip'
else:
module = __import__('rpi_ws281x', None, None, ['PixelStrip'])
class_name = 'PixelStrip'
loaded_class = getattr(module, class_name)
self._strip = loaded_class(
self.conf('STRIP_PIXELS'), self.conf('STRIP_GPIO_PIN'), self.conf('STRIP_HZ'), self.conf('STRIP_DMA'),
self.conf('STRIP_INVERT'), self.conf('STRIP_BRIGHTNESS'), self.conf('STRIP_CHANNEL'),
self.conf('STRIP_TYPE')
)
self._loops = [] self._loops = []
self._strip.begin() self._strip.begin()
self._regions: Regions = Regions(self.count_pixels(), self.conf('REGIONS'))
self._tempo: Tempo = Tempo(self.conf('BPM_DEFAULT'))
self._selected_program = None
def count_pixels(self) -> int: def count_pixels(self) -> int:
return self._strip.numPixels() return self._strip.numPixels()
@@ -19,7 +33,10 @@ class Litsimaja(object):
return self._strip return self._strip
def set_pixel_color(self, n: int, color: int) -> None: def set_pixel_color(self, n: int, color: int) -> None:
self._strip.setPixelColor(n, color) if self._regions.is_pixel_enabled(n):
self._strip.setPixelColor(n, color)
else:
self._strip.setPixelColor(n, 0)
def show(self) -> None: def show(self) -> None:
self._strip.show() self._strip.show()
@@ -32,3 +49,33 @@ class Litsimaja(object):
for loop in self._loops: for loop in self._loops:
loop.stop() loop.stop()
self._loops.clear() self._loops.clear()
def switch_region(self, region_id: int):
self._regions.switch_region(region_id)
def get_region_ids(self):
return self._regions.list_region_ids()
def build_status_array(self):
data = {'success': True}
features = {
'program': self._selected_program,
'tempo': {
'bpm': self.get_tempo().get_bpm()
},
}
regions = []
for region_id in self._regions.list_region_ids():
regions.append(self._regions.is_region_enabled(region_id))
features['region'] = regions
data['features'] = features
return data
def get_tempo(self):
return self._tempo
def set_selected_program(self, program_name: str):
self._selected_program = program_name
def conf(self, key: str):
return self._config.get(key)

View File

@@ -15,6 +15,7 @@ def run(namespace: str, class_name: str, lm: Litsimaja, logger, args: [] = None)
program = loaded_class(lm) program = loaded_class(lm)
logger.info('Loaded "' + module.name() + '" from ' + namespace + '.' + class_name + ' with args: ' + repr(args)) logger.info('Loaded "' + module.name() + '" from ' + namespace + '.' + class_name + ' with args: ' + repr(args))
lm.add_loop(program.get_loop()) lm.add_loop(program.get_loop())
lm.set_selected_program(namespace + '.' + class_name)
program.run(args) program.run(args)

38
pyleds/lib/Regions.py Normal file
View File

@@ -0,0 +1,38 @@
class Regions(object):
_pixelsEnabled: [] = []
_regions: [] = []
def __init__(self, strip_length: int, splitters: []):
self._length: int = strip_length
start_pixel = 0
for i in splitters:
if i > self._length:
raise ValueError('splitter out of bounds, you idiot')
self._regions.append([start_pixel, i + 1, True])
start_pixel = i + 1
self._regions.append([start_pixel, self._length, True])
for i in range(self._length):
self._pixelsEnabled.append(True)
def region_switch(self, region_id: int, enabled: bool):
pixel_region = self._regions[region_id]
for i in range(pixel_region[0], pixel_region[1]):
self._pixelsEnabled[i] = enabled
self._regions[region_id][2] = enabled
def is_pixel_enabled(self, pixel_id: int) -> bool:
return self._pixelsEnabled[pixel_id]
def is_region_enabled(self, region_id: int) -> bool:
return self._regions[region_id][2]
def switch_region(self, region_id: int):
status: bool = self._regions[region_id][2]
self.region_switch(region_id, not status)
def list_region_ids(self):
result = []
for i in range(len(self._regions)):
result.append(i)
return result

45
pyleds/lib/Tempo.py Normal file
View File

@@ -0,0 +1,45 @@
import time
from typing import Optional
class Tempo(object):
_last_beat: float
_override_beat: Optional[float] = None
def __init__(self, bpm: float = 60):
self._bpm: float = bpm
self.update_last_beat()
def set_bpm(self, bpm: float) -> None:
self._bpm = bpm
def get_bpm(self) -> float:
return self._bpm
def update_last_beat(self) -> None:
self._last_beat = time.time()
def get_beat_time(self) -> float:
return 1000 / (self._bpm / 60)
def get_beat_delay(self) -> Optional[float]:
tick = self._override_beat
if tick is None:
return None
self._override_beat = None
return tick - self._last_beat
def sync_beat(self):
self._override_beat = time.time()
def wait(self, beat_divider: int = 1):
now = time.time()
next_beat = self._last_beat + (self.get_beat_time() / beat_divider / 1000)
wait_time = next_beat - now
beat_delay = self.get_beat_delay()
if beat_delay is not None:
wait_time += beat_delay
if wait_time > 0:
time.sleep(wait_time)
self.update_last_beat()
return True

View File

@@ -30,7 +30,8 @@ class DiskoPidu(Program):
loop = args['loop'] loop = args['loop']
while self.get_loop().status(): while self.get_loop().status():
self.disco(10, 500) self._lm.get_tempo().wait()
self.disco(10, 0)
if not loop: if not loop:
break break

View File

@@ -0,0 +1,35 @@
from lib.Program import Program
from rpi_ws281x import Color
import time
def name():
return 'RGBT Gaynbow'
def wheel(pos):
"""Generate rainbow colors across 0-255 positions."""
if pos < 85:
return Color(pos * 3, 255 - pos * 3, 0)
elif pos < 170:
pos -= 85
return Color(255 - pos * 3, 0, pos * 3)
else:
pos -= 170
return Color(0, pos * 3, 255 - pos * 3)
class Gaynbow(Program):
def run(self, args: [] = None):
wait_ms = 20
iterations = 5
while self.get_loop().status():
"""Draw rainbow that uniformly distributes itself across all pixels."""
for j in range(256 * iterations):
if not self.get_loop().status():
break
for i in range(self._lm.count_pixels()):
self._lm.set_pixel_color(i, wheel(
(int(i * 256 / self._lm.count_pixels()) + j) & 255))
self._lm.show()
time.sleep(wait_ms / 1000.0)

View File

@@ -0,0 +1,22 @@
from lib.Program import Program
import time
def name():
return '1Hz tick'
# Tick seconds like analog clock
class HzTick(Program):
def run(self, args: [] = None) -> None:
tempo = self._lm.get_tempo()
while self.get_loop().status():
second = time.localtime().tm_sec
second_pixel = int(self._lm.count_pixels() / 60 * second)
color_arr = args['color']
for i in range(self._lm.count_pixels()):
self._lm.set_pixel_color(i, (color_arr[0] << 16) | (color_arr[1] << 8) | color_arr[2])
self._lm.set_pixel_color(second_pixel, 255)
self._lm.show()
tempo.wait()

View File

@@ -0,0 +1,35 @@
from lib.Program import Program
from rpi_ws281x import Color
import time
def name():
return 'Vikermasetsus'
def wheel(pos):
"""Generate rainbow colors across 0-255 positions."""
if pos < 85:
return Color(pos * 3, 255 - pos * 3, 0)
elif pos < 170:
pos -= 85
return Color(255 - pos * 3, 0, pos * 3)
else:
pos -= 170
return Color(0, pos * 3, 255 - pos * 3)
class Vikermasetsus(Program):
def run(self, args: [] = None):
wait_ms = 20
iterations = 5
while self.get_loop().status():
"""Draw rainbow that uniformly distributes itself across all pixels."""
for j in range(256 * iterations):
if not self.get_loop().status():
break
for i in range(self._lm.count_pixels()):
self._lm.set_pixel_color(i, wheel(
(int(i * 2560 / self._lm.count_pixels()) + j) & 255))
self._lm.show()
time.sleep(wait_ms / 1000.0)

View File

@@ -1,42 +1,54 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import logging import logging
import pathlib
import sys import sys
import lib.ProgramLoading as Pl import lib.ProgramLoading as Pl
from lib.Litsimaja import Litsimaja from lib.Litsimaja import Litsimaja
from flask import Flask, request, Response, render_template from flask import Flask, request, Response, render_template, json
root_path = pathlib.Path(__file__).parent.absolute()
# start litsimaja
lm = Litsimaja()
# logging # logging
logger = logging.getLogger('litsimaja') logger = logging.getLogger('litsimaja')
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG if lm.conf('IS_DEV') else logging.WARN)
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
file_handler = logging.FileHandler('litsimaja.log') file_handler = logging.FileHandler(str(root_path) + '/litsimaja.log')
file_handler.setFormatter(formatter) file_handler.setFormatter(formatter)
logger.addHandler(file_handler) logger.addHandler(file_handler)
stdout_handler = logging.StreamHandler(sys.stdout) stdout_handler = logging.StreamHandler(sys.stdout)
stdout_handler.setFormatter(formatter) stdout_handler.setFormatter(formatter)
logger.addHandler(stdout_handler) logger.addHandler(stdout_handler)
# start litsimaja
lm = Litsimaja()
app = Flask(__name__, static_url_path='', static_folder='templates') app = Flask(__name__, static_url_path='', static_folder='templates')
Pl.run('siinus', 'Wipes', lm, logger, {'color': [0, 0, 0]})
Pl.run('peter', 'DiskoPidu', lm, logger, {})
def lm_standard_xhr_response() -> Response:
return Response(response=json.dumps(lm.build_status_array()), status=200, mimetype='application/json')
@app.route('/', methods=['GET']) @app.route('/', methods=['GET'])
def respondroot(): def respond_root():
return render_template('index.html', programs=Pl.list_all(True)) return render_template(
'index.html',
programs=Pl.list_all(True),
regions=lm.get_region_ids(),
status=lm.build_status_array()
)
@app.route('/run') @app.route('/status', methods=['GET'])
def run(): def respond_status():
Pl.run('siinus', 'MyProgram', lm, logger, {'color': [50, 50, 50]}) return lm_standard_xhr_response()
return Response(status=200)
@app.route('/crash', methods=['GET']) @app.route('/crash', methods=['GET'])
def crash(): def crash():
lm.clear_loops() lm.clear_loops()
return Response(status=200) return lm_standard_xhr_response()
@app.route('/program/<program>', methods=['POST']) @app.route('/program/<program>', methods=['POST'])
@@ -44,7 +56,26 @@ def run_program(program):
args = request.get_json(force=True) args = request.get_json(force=True)
prg = program.split('.') prg = program.split('.')
Pl.run(prg[0], prg[1], lm, logger, args) Pl.run(prg[0], prg[1], lm, logger, args)
return Response(status=200) return lm_standard_xhr_response()
app.run('0.0.0.0', 8080) @app.route('/region/<region>', methods=['GET'])
def switch_region(region):
lm.switch_region(int(region))
return lm_standard_xhr_response()
@app.route('/tempo/set/<float:bpm>', methods=['GET'])
def set_tempo(bpm: float):
tempo = lm.get_tempo()
tempo.set_bpm(bpm)
return lm_standard_xhr_response()
@app.route('/tempo/sync', methods=['GET'])
def sync_beat():
lm.get_tempo().sync_beat()
return lm_standard_xhr_response()
app.run(lm.conf('BIND_ADDR'), lm.conf('BIND_PORT'))

View File

@@ -1,13 +1,3 @@
.section-rgb {
height: 20rem;
text-align: center;
}
select {
width: 100%;
height: calc(100% - 1.5rem);
}
.colorpicker { .colorpicker {
display: inline-block; display: inline-block;
height: 38px; height: 38px;
@@ -28,3 +18,32 @@ select {
.spacer-row { .spacer-row {
height: 2rem; height: 2rem;
} }
button.region {
padding: 0 15;
}
button.region_off {
background-color: gray !important
}
div.program_select {
height: 300px;
overflow: auto;
border: 1px solid gray;
text-align: left;
}
div.program_select ul {
list-style: none;
border-bottom: 1px solid silver;
margin-bottom: 0;
}
div.program_select ul li {
margin: auto;
}
div.program_select ul li label {
margin: 3px auto;
}

View File

@@ -7,11 +7,16 @@
<link rel="stylesheet" href="custom.css"> <link rel="stylesheet" href="custom.css">
<script type="text/javascript"> <script type="text/javascript">
const hexToRgb = hex => 'use strict';
hex.replace(/^#?([a-f\d])([a-f\d])([a-f\d])$/i function hexToRgb(value) {
,(m, r, g, b) => '#' + r + r + g + g + b + b) var aRgbHex = value.substring(1).match(/.{1,2}/g);
.substring(1).match(/.{2}/g) var aRgb = [
.map(x => parseInt(x, 16)); parseInt(aRgbHex[0], 16),
parseInt(aRgbHex[1], 16),
parseInt(aRgbHex[2], 16)
];
return aRgb;
}
function show_loading(visible) { function show_loading(visible) {
document.getElementById('loading').style.visibility = visible ? 'visible' : 'hidden'; document.getElementById('loading').style.visibility = visible ? 'visible' : 'hidden';
@@ -21,9 +26,11 @@
show_loading(true); show_loading(true);
var http = new XMLHttpRequest(); var http = new XMLHttpRequest();
http.open('GET', url, true); http.open('GET', url, true);
http.setRequestHeader('accept', 'application/json');
http.onreadystatechange = function() { http.onreadystatechange = function() {
if (http.readyState == 4 && http.status == 200) { if (http.readyState == 4 && http.status == 200) {
show_loading(false); show_loading(false);
updateStatus(http.responseText);
} }
}; };
http.send(); http.send();
@@ -34,6 +41,7 @@
var http = new XMLHttpRequest(); var http = new XMLHttpRequest();
http.open('POST', url, true); http.open('POST', url, true);
http.setRequestHeader('Content-Type', 'application/json'); http.setRequestHeader('Content-Type', 'application/json');
http.setRequestHeader('accept', 'application/json');
http.onreadystatechange = function() { http.onreadystatechange = function() {
if (http.readyState == 4 && http.status == 200) { if (http.readyState == 4 && http.status == 200) {
show_loading(false); show_loading(false);
@@ -43,9 +51,33 @@
http.send(json); http.send(json);
} }
function updateStatus(json) {
let status = JSON.parse(json);
document.getElementById('tempo').value = status.features.tempo.bpm;
let regions = status.features.region;
for (let i = 0, len = regions.length; i < len; i++) {
if (regions[i]) {
document.getElementById('region_' + i).classList.remove('region_off');
} else {
document.getElementById('region_' + i).classList.add('region_off');
}
}
let programs = document.getElementsByName('program_select');
for (var i = 0, len = programs.length; i < len; i++) {
if (programs[i].value === status.features.program) {
programs[i].checked = true;
}
}
}
function getProgramSelection() { function getProgramSelection() {
let select = document.getElementById('program'); let radios = document.getElementsByName('program_select');
return select.value; for (var i = 0, length = radios.length; i < length; i++) {
if (radios[i].checked) {
return radios[i].value;
}
}
return null;
} }
function isLoop() { function isLoop() {
@@ -70,6 +102,10 @@
function doCancel() { function doCancel() {
send_get('/crash'); send_get('/crash');
} }
function switchRegion(region_id) {
send_get('/region/' + region_id);
}
</script> </script>
</head> </head>
@@ -77,32 +113,53 @@
<div class="spacer-row"></div> <div class="spacer-row"></div>
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<button onclick="doInit()">init</button> <button onclick="send_get('/status')">get</button>
<button onclick="doCancel()">end loop</button> <button onclick="doInit()">intro</button>
<button onclick="doBlind()">off</button> <button onclick="doCancel()">halt</button>
<button onclick="doBlind()">leds off</button>
<label><input type="checkbox" id="looping" /><b> loop</b></label> <label><input type="checkbox" id="looping" checked/><b> loop</b></label>
</div>
<div class="row">
<input id="tempo" type="number" step="0.1" onchange="send_get('/tempo/set/' + parseFloat(this.value).toFixed(2))" value="{{ status.features.tempo.bpm }}">
<button onclick="send_get('/tempo/sync')">sync</button>
</div>
<div class="row">
{% for region in regions %}
{% set region_off = '' %}
{% if not status.features.region[region] %}
{% set region_off = ' region_off' %}
{% endif %}
<button id="region_{{ region }}" class="region{{ region_off }}" onclick="switchRegion({{ region }})">zone {{ region }}</button>
{% endfor %}
</div> </div>
<div class="row" id='loading' style="visibility: hidden"><b>LOADING!</b></div> <div class="row" id='loading' style="visibility: hidden"><b>LOADING!</b></div>
<div class="section-rgb"> <div class="row">
<div class="row"> <div class="program_select one-half column">
<div class="one-third column"> {% for group in programs %}
<select id="program" size="10"> <ul>
{% for group in programs %} <li>{{ group.group }}</li>
<optgroup label="{{ group.group }}"> {% for program in group.programs %}
{% for program in group.programs %} {% set pr_checked = '' %}
<option value="{{ program.prg }}">{{ program.name }}</option> {% if status.features.program == program.prg %}
{% endfor %} {% set pr_checked = ' checked' %}
</optgroup> {% endif %}
<li>
<label>
<input type="radio" name="program_select" value="{{ program.prg }}"{{ pr_checked }}>
{{ program.name }}
</label>
</li>
{% endfor %} {% endfor %}
</select> </ul>
</div> {% endfor %}
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class ="one-third column"> <div class ="one-third column">
<input class="colorpicker" type="color" value="#ff0000" oninput="isChanged(this.value)"> <input id="colorpicker" class="colorpicker" type="color" value="#ff0000" oninput="isChanged(this.value)">
</div> </div>
<button onclick="isChanged(document.getElementById('colorpicker').value)">Set</button>
</div> </div>
</div> </div>
</body> </body>