~jojo/jojos-hue

6a5bfbbc145e573e6e6639805684b3dcb5336a98 — JoJo 12 days ago 55988d3
custom UDP protocol & offload FastLED to second core on ESP32

Had problems with a bit of lagging in rendering on the
ESP32_WS2801. Presumed it was bc of TCP resends etc, so switced to
UDP. It's really not important that we display every single frame, and
in exactly the right order. In particular, if a packet is lost or
smth, we would rather show a newer frame than have an old one resent.

Also, I've had trouble with single-wire strips like WS2813 on the
ESP32. Issue seems to be that it can't handle the relatively strict
timing requirements, compared to WS2801 where we send the clock signal
ourselves. Making use of the fact that Arduino for ESP32 is dualcore &
apparently runs FreeRTOS behind the scenes, we run FastLED.show() in a
separate task pinned to the second core. This seems to have solved the
issue perfectly for me.
M client/client.ino => client/client.ino +122 -63
@@ 2,9 2,9 @@

#define UNO_WS2812B
// #define ESP32_WS2801
// #define ESP32_WS2813

// vvvvvvv UNIT vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
// ---------------------------------------------------------
#if                               defined(UNO_WS2812B)
#define LED_PINS 8
#define LED_TYPE WS2812B


@@ 13,7 13,6 @@
#define MAX_MILLIAMPS 2000
static byte mymac[] = { 0x74,0x69,0x69,0x2D,0x30,0x31 };
#define CONN_ETH
// ---------------------------------------------------------
#elif                             defined(ESP32_WS2801)
#define LED_PINS G33, G23
#define LED_TYPE WS2801


@@ 21,7 20,14 @@ static byte mymac[] = { 0x74,0x69,0x69,0x2D,0x30,0x31 };
#define NUM_LEDS 50
#define MAX_MILLIAMPS 3000
#define CONN_WIFI
// ---------------------------------------------------------
#elif                             defined(ESP32_WS2813)
#define LED_PINS G33
#define LED_TYPE WS2813
#define COLOR_ORDER GRB
#define NUM_LEDS 60
#define MAX_MILLIAMPS 2000
#define DUALCORE_FASTLED
#define CONN_WIFI
#                                 else
#error No unit defined (e.g. ESP32_WS2801)
#endif


@@ 32,18 38,14 @@ static byte mymac[] = { 0x74,0x69,0x69,0x2D,0x30,0x31 };
// ---------------------------------------------------------
#if                               defined(CONN_ETH)
#include <Ethernet.h>
EthernetClient client;
EthernetUDP udp;
#define NET
// ---------------------------------------------------------
#elif                             defined(CONN_WIFI)
#include <WiFi.h>
// #include <WiFiClient.h>
// #include <WebServer.h>
// #include <ESPmDNS.h>
#include <WiFiUdp.h>
#include "secrets.h"
WiFiClient client;
WiFiUDP udp;
#define NET
// ---------------------------------------------------------
#                                 else
// ...
#endif


@@ 51,10 53,44 @@ WiFiClient client;
#ifdef NET
#define SERVER_IP "192.168.0.41"
#define SERVER_PORT 7017
#define LOCAL_PORT 7711
#endif

#define READ_RETRIES 2
#define NUM_BYTES (3*NUM_LEDS)

// ESP32 seems to have a hard time keeping up with the timings of a single-wire LED strip
// like WS2812b or WS2813. This might be a result of using WiFi at the same time, with the
// WiFi code blocking us enough to throw off the timings. A workaround that seems to work
// well is to run FastLED.show() on it's own core, which we can do since ESP32 is
// dualcore.
#ifdef DUALCORE_FASTLED
#define FASTLED_SHOW_CORE 1
static TaskHandle_t showTaskHandle = 0;
static TaskHandle_t userTaskHandle = 0;
void show() {
    if (userTaskHandle == 0) {
        userTaskHandle = xTaskGetCurrentTaskHandle();
        xTaskNotifyGive(showTaskHandle);
        const TickType_t xMaxBlockTime = pdMS_TO_TICKS(200);
        ulTaskNotifyTake(pdTRUE, xMaxBlockTime);
        userTaskHandle = 0;
    }
}
void showTask(void *pvParameters) {
    for (;;) {
        ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
        FastLED.show();
        xTaskNotifyGive(userTaskHandle);
    }
}
#else
void show() {
    FastLED.show();
}
#endif


CRGB leds[NUM_LEDS];

void setAll(CRGB c) {


@@ 78,6 114,7 @@ void setup() {
    Serial.println("online");
    Serial.print("IP address: ");
    Serial.println(Ethernet.localIP());
    udp.begin(LOCAL_PORT);
#elif defined(CONN_WIFI)
    WiFi.mode(WIFI_STA);
    WiFi.begin(ssid, password);


@@ 92,11 129,15 @@ void setup() {
    // if (MDNS.begin("esp32")) {
    //     Serial.println("MDNS responder started");
    // }
    udp.begin(LOCAL_PORT);
#endif

    FastLED.addLeds<LED_TYPE, LED_PINS, COLOR_ORDER>(leds, NUM_LEDS)
        .setCorrection(TypicalLEDStrip);
    FastLED.setMaxPowerInVoltsAndMilliamps(5, MAX_MILLIAMPS);
#ifdef DUALCORE_FASTLED
    xTaskCreatePinnedToCore(showTask, "showTask", 2048, NULL, 2, &showTaskHandle, FASTLED_SHOW_CORE);
#endif
    CRGB initCols[] = {
        CRGB(255, 0, 0),
        CRGB(0, 255, 0),


@@ 106,78 147,69 @@ void setup() {
    };
    for (uint8_t i = 0; i < sizeof(initCols)/sizeof(CRGB); i++) {
        setAll(initCols[i]);
        FastLED.show();
        show();
        delay(1000);
    }
    Serial.println("setup finished");
}

#ifdef NET
bool connected() {
    return (bool)client.connected();
}
bool connect() {
    return (bool)client.connect(SERVER_IP, SERVER_PORT);
}
uint16_t readMax(uint16_t n, byte* buf) {
    int r = client.read(buf, n);
    if (udp.available() <= 0) {
        udp.parsePacket();
    }
    int r = udp.read(buf, n);
    return max(0, r);
}

uint16_t writeMax(uint16_t n, const byte* buf) {
    int r = client.write(buf, n);
    return max(0, r);
void sendHeartbeat() {
    udp.beginPacket(SERVER_IP, SERVER_PORT);
    udp.write((const uint8_t*)"heart<", 6);
    const uint16_t n = NUM_LEDS;
    udp.write((const byte*)&n, 2);
    udp.write((const uint8_t*)">beat", 5);
    udp.endPacket();
}
#endif

void writeExact(uint16_t, byte*);

void ensureConnected() {
    if (!connected()) {
        Serial.println("disconnected. connecting...");
        uint16_t tries = 0;
        const uint16_t max_tries = 200;
        while (!connect()) {
            if (tries == max_tries) {
                Serial.println("server seems to be offline");
                Serial.println("will turn off leds and keep trying");
                setAll(CRGB(0, 0, 0));
                FastLED.show();
            }
            tries = min(max_tries, tries) + 1;
            delay(300);
        }
        uint16_t x = NUM_LEDS;
        // If the write fails, fuck it. The server will just timeout the read and
        // disconnect us again and we'll retry.
        writeMax(2, (byte*)&x);
        Serial.println("connected");
void keepConnectionAlive() {
    const uint16_t interval = 500 / READ_RETRIES;
    static uint16_t n = interval;
    n += 1;
    if (n > interval) {
        sendHeartbeat();
        n = 0;
    }
}
#endif

void readExact(uint16_t n, byte* buf) {
// Returns false on timeout
bool readExact(uint16_t n, byte* buf) {
    uint16_t retry = 0;
    while (n > 0) {
        uint16_t m = readMax(n, buf);
        if (m == 0) {
            ensureConnected();
            delay(10);
            retry += 1;
            if (retry > READ_RETRIES) {
                return false;
            }
            delay(1);
        } else {
            n -= m;
            buf += m;
        }
        n -= m;
        buf += m;
    }
    return true;
}

byte readSingle() {
// Blocks until one byte read. Returns -1 on timeout
int readSingle() {
    byte x;
    readExact(1, &x);
    return x;
    return readExact(1, &x) ? x : -1;
}

#define MAGIC_COOKIE_LEN 5
const byte magicCookie[MAGIC_COOKIE_LEN] = {'s','a','t','a','n'};

bool isMagicCookie(byte* buf) {
    uint16_t i = 0;
    for (uint16_t i = 0; i < MAGIC_COOKIE_LEN; i++) {
        if (magicCookie[i] != buf[i]) {
            return false;


@@ 186,31 218,58 @@ bool isMagicCookie(byte* buf) {
    return true;
}

void sync() {
// Blockingly skips until synced. Returns false if timeout reached.
bool synchronize() {
    byte buf[MAGIC_COOKIE_LEN];
    readExact(MAGIC_COOKIE_LEN, buf);
    if (!readExact(MAGIC_COOKIE_LEN, buf)) {
        return false;
    }
    if (isMagicCookie(buf)) {
        return;
        return true;
    }
    Serial.println("out of sync");
    while (!isMagicCookie(buf)) {
        uint16_t i = 0;
        for (; i < MAGIC_COOKIE_LEN; i++) {
        for (; i < MAGIC_COOKIE_LEN-1; i++) {
            buf[i] = buf[i+1];
        }
        buf[i] = readSingle();
        int b = readSingle();
        if (b < 0) {
            return false;
        } else {
            buf[i] = (byte)b;
        }
    }
    return true;
}

byte frame[NUM_BYTES];

void readFrame() {
    sync();
    readExact(NUM_BYTES, frame);
    setEach(frame);
    // Max number of timed out reads before we turn the lights off
    const uint16_t maxFails = 80000 / READ_RETRIES;
    static uint16_t fails = 0;
    // Blocks until we got sync and all data, unless timeout
    if (synchronize() && readExact(NUM_BYTES, frame)) {
        if (fails > maxFails) {
            Serial.println("we're back!");
        }
        fails = 0;
        setEach(frame);
    } else {
        fails += 1;
        if (fails == maxFails) {
            fails = maxFails + 1;
            Serial.println("server seems dead. lights off!");
            setAll(CRGB(0, 0, 0));
        }
    }
}

void loop() {
#ifdef NET
    keepConnectionAlive();
#endif
    readFrame();
    FastLED.show();
    show();
}

M server/Cargo.lock => server/Cargo.lock +102 -0
@@ 63,6 63,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28ae2b3dec75a406790005a200b1bd89785afc02517a00ca99ecfe093ee9e6cf"

[[package]]
name = "approx"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "072df7202e63b127ab55acfe16ce97013d5b97bf160489336d3f1840fd78e99e"
dependencies = [
 "num-traits",
]

[[package]]
name = "arrayref"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 603,6 612,15 @@ dependencies = [
]

[[package]]
name = "find-crate"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59a98bbaacea1c0eb6a0876280051b892eb73594fd90cf3b20e9c817029c57d2"
dependencies = [
 "toml",
]

[[package]]
name = "form_urlencoded"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 857,6 875,7 @@ version = "0.1.0"
dependencies = [
 "async-std",
 "byteorder",
 "palette",
 "serde",
 "tide",
]


@@ 955,6 974,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"

[[package]]
name = "palette"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9735f7e1e51a3f740bacd5dc2724b61a7806f23597a8736e679f38ee3435d18"
dependencies = [
 "approx",
 "num-traits",
 "palette_derive",
 "phf",
]

[[package]]
name = "palette_derive"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7799c3053ea8a6d8a1193c7ba42f534e7863cf52e378a7f90406f4a645d33bad"
dependencies = [
 "find-crate",
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "parking"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 967,6 1010,50 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e"

[[package]]
name = "phf"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2ac8b67553a7ca9457ce0e526948cad581819238f4a9d1ea74545851fa24f37"
dependencies = [
 "phf_macros",
 "phf_shared",
 "proc-macro-hack",
]

[[package]]
name = "phf_generator"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d43f3220d96e0080cc9ea234978ccd80d904eafb17be31bb0f76daaea6493082"
dependencies = [
 "phf_shared",
 "rand 0.8.4",
]

[[package]]
name = "phf_macros"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b706f5936eb50ed880ae3009395b43ed19db5bff2ebd459c95e7bf013a89ab86"
dependencies = [
 "phf_generator",
 "phf_shared",
 "proc-macro-hack",
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "phf_shared"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a68318426de33640f02be62b4ae8eb1261be2efbc337b60c54d845bf4484e0d9"
dependencies = [
 "siphasher",
]

[[package]]
name = "pin-project"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 1283,6 1370,12 @@ dependencies = [
]

[[package]]
name = "siphasher"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "533494a8f9b724d33625ab53c6c4800f7cc445895924a8ef649222dcb76e938b"

[[package]]
name = "slab"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 1493,6 1586,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c"

[[package]]
name = "toml"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31142970826733df8241ef35dc040ef98c679ab14d7c3e54d827099b3acecaa"
dependencies = [
 "serde",
]

[[package]]
name = "typenum"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"

M server/Cargo.toml => server/Cargo.toml +2 -1
@@ 9,4 9,5 @@ edition = "2018"
byteorder = "*"
tide = "0.16"
async-std = { version = "1.10", features = ["attributes"] }
serde = { version = "1.0", features = ["derive"] }
\ No newline at end of file
serde = { version = "1.0", features = ["derive"] }
palette = "0.6"

M server/src/main.rs => server/src/main.rs +110 -62
@@ 1,22 1,26 @@
use byteorder::{ByteOrder, LittleEndian};
use palette::{FromColor, Hsv, RgbHue, Srgb};
use std::{
    io::{Read, Write},
    collections::HashMap,
    f32::consts as f32,
    iter::once,
    net::{SocketAddr, TcpListener, TcpStream},
    sync::mpsc::{channel, sync_channel, Receiver, Sender, SyncSender},
    net::{SocketAddr, UdpSocket},
    sync::{
        mpsc::{channel, sync_channel, Receiver, Sender, SyncSender},
        Arc,
    },
    thread,
    time::{Duration, Instant},
};
use tide::{prelude::*, Request};

const FPS: f32 = 120.0;
const TIMEOUT: Duration = Duration::from_millis(200);
const FPS: f32 = 100.0;
const DEADLINE: Duration = Duration::from_secs(10);

type Con = (TcpStream, SocketAddr);
type Client = (Sender<Vec<RGB>>, u16);
type Heartbeat = (SocketAddr, u16);

#[derive(Clone, Copy, Deserialize, Debug)]
struct RGB {
#[derive(Clone, Copy, Deserialize)]
struct QueryRgb {
    r: f32,
    g: f32,
    b: f32,


@@ 24,63 28,100 @@ struct RGB {

#[derive(Clone, Copy, Debug)]
enum Cmd {
    All(RGB),
    All(Srgb),
    Off,
    Cycle,
    Breathing,
}

#[async_std::main]
async fn main() -> tide::Result<()> {
    let (clients_tx, clients_rx) = channel();
    let (heartbeat_tx, heartbeat_rx) = channel();
    let (cmd_tx, cmd_rx) = sync_channel(8);

    thread::spawn(move || {
        let listener = TcpListener::bind("0.0.0.0:7017").unwrap();
        let listener = UdpSocket::bind("192.168.0.41:7017").unwrap();
        loop {
            if let Some(c) = listener.accept().ok().and_then(setup_client) {
                clients_tx.send(c).unwrap();
            let mut msg = [0; 13];
            if let Some((_, addr)) = listener.recv_from(&mut msg).ok() {
                if let Some(beat) = parse_heartbeat(&msg, addr) {
                    heartbeat_tx.send(beat).unwrap();
                } else {
                    println!(
                        "Malformed message from {}, {:?} / {:?}",
                        addr,
                        String::from_utf8_lossy(&msg),
                        msg
                    )
                }
            }
        }
    });

    thread::spawn(move || controller(clients_rx, cmd_rx));
    thread::spawn(move || controller(heartbeat_rx, cmd_rx));

    let mut web = tide::new();
    web.at("/").serve_file("static/index.html")?;
    web.at("/static").serve_dir("static/")?;
    let cmd_tx1 = cmd_tx.clone();
    web.at("/all")
        .get(move |req| all_handler(req, cmd_tx.clone()));
    web.at("/proc/:name")
        .get(move |req| proc_handler(req, cmd_tx1.clone()));
    web.listen("192.168.0.41:8558").await?;
    Ok(())
}

async fn all_handler(req: Request<()>, tx: SyncSender<Cmd>) -> tide::Result {
    let rgb = req.query()?;
    tx.send(Cmd::All(rgb)).unwrap();
    let c: QueryRgb = req.query()?;
    tx.send(Cmd::All(Srgb::new(c.r, c.g, c.b))).unwrap();
    Ok("cool, ty".into())
}

fn controller(clients_rx: Receiver<Client>, cmd_rx: Receiver<Cmd>) {
    let mut clients = Vec::new();
async fn proc_handler(req: Request<()>, tx: SyncSender<Cmd>) -> tide::Result {
    let s = req.param("name")?;
    match s {
        "off" => tx.send(Cmd::Off).unwrap(),
        "cycle" => tx.send(Cmd::Cycle).unwrap(),
        "breathing" => tx.send(Cmd::Breathing).unwrap(),
        _ => {}
    }
    Ok("cool, ty".into())
}

fn controller(heartbeat_rx: Receiver<Heartbeat>, cmd_rx: Receiver<Cmd>) {
    let t0 = Instant::now();
    let sender = Arc::new(UdpSocket::bind("192.168.0.41:7018").unwrap());
    let mut clients = HashMap::<SocketAddr, (Instant, Sender<_>)>::new();
    let mut tprev = Instant::now();
    let mut cmd = Cmd::All(RGB {
        r: 0.0,
        g: 0.0,
        b: 0.0,
    });
    let mut cmd = Cmd::All(Srgb::new(0.0, 0.0, 0.0));
    loop {
        if let Ok(client) = clients_rx.try_recv() {
            clients.push(client);
        while let Ok((addr, nleds)) = heartbeat_rx.try_recv() {
            match clients.get_mut(&addr) {
                Some(client) => client.0 = Instant::now(),
                None => {
                    clients.insert(
                        addr,
                        (Instant::now(), setup_client(addr, nleds, sender.clone())),
                    );
                }
            }
        }

        let t = t0.elapsed().as_secs_f64();
        if let Ok(new_cmd) = cmd_rx.try_recv() {
        clients.retain(|addr, (tbeat, _)| {
            let live = tbeat.elapsed() < DEADLINE;
            if !live {
                println!("Sadly, client {} hasn't had a hearbeat for a while, and must thus be pronounced dead!", addr);
            }
            live
        });
        while let Some(new_cmd) = cmd_rx.try_iter().last() {
            println!("New command: {:?}", new_cmd);
            cmd = new_cmd;
        }
        clients = clients
            .into_iter()
            .filter_map(|(tx, nleds)| tx.send(lightf(cmd, t, nleds)).map(|()| (tx, nleds)).ok())
            .collect();
        for (_, tx) in clients.values() {
            let t = t0.elapsed().as_secs_f64();
            tx.send((cmd, t)).ok();
        }
        let dt = tprev.elapsed();
        tprev = Instant::now();
        let period = Duration::from_secs_f32(1.0 / FPS);


@@ 90,49 131,56 @@ fn controller(clients_rx: Receiver<Client>, cmd_rx: Receiver<Cmd>) {
    }
}

fn setup_client((mut stream, addr): Con) -> Option<Client> {
    stream
        .set_write_timeout(Some(TIMEOUT))
        .map_err(|e| println!("Error setting write timeout. {:?}", e))
        .ok()?;
    let mut buf = [0; 2];
    stream
        .read_exact(&mut buf)
        .map_err(|e| println!("Error reading client num leds. {:?}", e))
        .ok()?;
    let nleds = LittleEndian::read_u16(&buf);
    let (tx, rx) = channel::<Vec<RGB>>();
fn setup_client(addr: SocketAddr, nleds: u16, sender: Arc<UdpSocket>) -> Sender<(Cmd, f64)> {
    let (tx, rx) = channel::<(Cmd, f64)>();
    thread::spawn(move || loop {
        let magic_cookie = b"satan";
        let color_data = rx
        let (cmd, t) = rx
            .recv()
            .unwrap()
            .expect("Client channel closed. Time for this thread to die!");
        let color_data = lightf(cmd, t, nleds)
            .into_iter()
            .flat_map(|RGB { r, g, b }| once(r).chain(once(g)).chain(once(b)))
            .flat_map(|c| once(c.red).chain(once(c.green)).chain(once(c.blue)))
            .map(|x| (x * 256.0).min(256.0).max(0.0) as u8);
        let data = magic_cookie
            .iter()
            .cloned()
            .chain(color_data)
            .collect::<Vec<u8>>();
        if let Err(_) = stream.write_all(&data) {
            println!("Error writing to client {}. Dropping them.", addr);
            return;
        }
        sender.send_to(&data, addr).ok();
    });
    println!("New client {} with {} LEDs", addr, nleds);
    Some((tx, nleds))
    tx
}

fn parse_heartbeat(msg: &[u8; 13], addr: SocketAddr) -> Option<Heartbeat> {
    if msg.starts_with(b"heart<") && msg.ends_with(b">beat") {
        let nleds = LittleEndian::read_u16(&msg[6..8]);
        println!("Heartbeat from {} with {} LEDs", addr, nleds);
        Some((addr, nleds))
    } else {
        None
    }
}

fn lightf(cmd: Cmd, _t: f64, nleds: u16) -> Vec<RGB> {
fn lightf(cmd: Cmd, t: f64, nleds: u16) -> Vec<Srgb> {
    match cmd {
        Cmd::All(c) => vec![c; nleds as usize],
        Cmd::Off => vec![Srgb::new(0.0, 0.0, 0.0); nleds as usize],
        Cmd::Cycle => {
            vec![
                Srgb::from_color(Hsv::new(RgbHue::from_radians(t / 2.0), 1.0, 1.0)).into_format();
                nleds as usize
            ]
        }
        Cmd::Breathing => {
            vec![
                Srgb::from_color(Hsv::new(
                    RgbHue::from_radians(1.9 * f32::PI),
                    1.0,
                    0.5 + 0.4 * (t * 0.8).sin() as f32
                ));
                nleds as usize
            ]
        }
    }
    // (0..nleds)
    //     .map(|_i| RGB {
    //         r: (t * 2.0).sin() as f32 * 0.5 + 0.5,
    //         g: (t * 5.0).sin() as f32 * 0.5 + 0.5,
    //         b: (t * 7.0).sin() as f32 * 0.5 + 0.5,
    //     })
    //     .collect()
}

A server/static/favicon.png => server/static/favicon.png +0 -0
M server/static/index.html => server/static/index.html +5 -0
@@ 1,5 1,6 @@
<html>
  <head>
    <link rel="shortcut icon" type="image/png" href="/static/favicon.png"/>
    <link rel="stylesheet" href="/static/reinvented-color-wheel.css">
    <link rel="stylesheet" href="/static/main.css">
  </head>


@@ 8,6 9,10 @@
      <h1>jojos hue</h1>

      <div id="color-picker-container"></div>

      <button id="off-button">OFF</button>
      <button id="cycle-button">Cycle</button>
      <button id="breathing-button">Breathing</button>
    </main>

    <script lang="javascript" src="/static/reinvented-color-wheel.js"></script>

M server/static/main.js => server/static/main.js +13 -2
@@ 11,13 11,11 @@ var colorWheel = new ReinventedColorWheel({
    // rgb: [255, 0, 0],
    // hex: "#ff0000",

    // appearance
    wheelDiameter: 900,
    wheelThickness: 80,
    handleDiameter: 40,
    wheelReflectsSaturation: true,

    // handler
    onChange: (color) => {
        // the only argument is the ReinventedColorWheel instance itself.
        console.log("rgb:", color.rgb[0], color.rgb[1], color.rgb[2]);


@@ 30,6 28,19 @@ var colorWheel = new ReinventedColorWheel({
    },
});

let nullaryButtons =
    [["off-button", "off"],
     ["cycle-button", "cycle"],
     ["breathing-button", "breathing"]];

nullaryButtons.forEach(([btn, proc]) => {
    document.getElementById(btn).onclick = () => {
        console.log("proc: ", proc);
        let url = "/proc/" + proc;
        fetch(url);
        console.log(url);
    };
});

// set color in HSV / HSL / RGB / HEX
// colorWheel.hsv = [240, 100, 100];