Squash all commits in order to remove personal information. Sorry, you don't get to see the history on this project...

This commit is contained in:
Jeroen Domburg 2022-06-20 19:22:29 +08:00
commit 2165990cfd
40 changed files with 99165 additions and 0 deletions

6
.gitmodules vendored Normal file
View file

@ -0,0 +1,6 @@
[submodule "www/cropperjs"]
path = "www/cropperjs"
url = https://github.com/fengyuanchen/cropperjs.git
[submodule "firmware/components/esp32-wifi-manager"]
path = firmware/components/esp32-wifi-manager
url = "https://github.com/Spritetm/esp32-wifi-manager.git"

37
README.md Normal file
View file

@ -0,0 +1,37 @@
* Workflow
Uploading:
- html/js: select image, crop, resize, upload to server
- upload php: use C program to convert to EPD.
Spit out preview (?), add epd to database w/ timestamp
epd:
- Wake up
If Internet:
* fetch index JSON file (contains OTA plus timestamps of last 10 images)
* Update files to make internal store match
* Update list of image shows: set to 0 for new images
- Take list of image shows; get lowest show count; show newest image with that count.
Flash:
Other shit: 0x10000
OTA1: 0x150000
OTA2: 0x150000
left: 0x150000
img size 134464
11 images -> 10 150000
Case uses M3 inserts: height 4mm dia 5mm
Corresponding screws: M3*6mm
Colors:
- Grab picture in 'normal' situation (daylight, curtains, ...)
- Open in Gimp
- Image -> precision: 16-bit, linear
- Filters -> blur (to get rid of grain)
- Curve -> make darker colors a bit more dark (so it mixes more white in there, making end image lighter)
- Filters -> levels, select black level, select white level

1
case/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
*.stl

303
case/case.scad Normal file
View file

@ -0,0 +1,303 @@
/*
* ----------------------------------------------------------------------------
* "THE BEER-WARE LICENSE" (Revision 42):
* Jeroen Domburg <jeroen@spritesmods.com> wrote this file. As long as you retain
* this notice you can do whatever you want with this stuff. If we meet some day,
* and you think this stuff is worth it, you can buy me a beer in return.
* ----------------------------------------------------------------------------
*/
$fs=0.2;
pcb_size=[155.5,130,1.6];
holes=[
[7.5,7.5,0],
[7.5,65,0],
[7.5,130-7.5,0],
[155.5-40.1,7.5,0],
[155.5-7.5,65,0],
[155.5-7.5,130-7.5,0],
[155.5/2,130+5.3,0] //battery compartment screw
];
btns=[
[64, 92.25, 0],
[91.5, 92.25, 0]
];
btn_d=10;
//location of the battery terminals
bat_plus_pos=[24.5, 122, 0];
bat_min_pos=[131, 122, 0];
//Size of the pocket for the battery terminal solder points
bat_term_stickout=[3, 6, 1.3];
bat_term_size=[0.65, 12, 12.5];
bat_dia=15; //aa battery
eink_pcb_offset=[16.2, 16.9, 0];
eink_size=[125.4, 99.5, 0.95];
eink_vis_size=[114.9, 85.8, 0]; //visible area size
eink_vis_offset=[5.25, eink_size.y-eink_vis_size.y-4.6, 0];
pcb_center=eink_pcb_offset+eink_vis_offset+eink_vis_size/2;
mat_size=[145,120,3]; //size of actual mat
mat_cutout=[eink_vis_size.x+15, eink_vis_size.y+15,0]; //cutout for mat in case
mat_vis_extra=1; //how much mm more to show than the visible eink area
case_size=[170,143,24];
case_th=1.5;
screw_insert_d=5;
screw_insert_h=4;
screw_insert_post_d=7;
screw_hole_d=8.5;
screw_thread_hole_d=3.5;
tolerance=0.2;
support_d=2.0+tolerance;
support_pos=[0,-30,-6];
support_angle=[30,0,0];
render=2;
if (render==1) {
intersection() {
rotate([0,0,00]) union() {
translate(-pcb_center) pcb();
translate([0,0,pcb_size.z]) color("beige") mat();
case_top();
case_bottom();
battery_lid();
}
translate([0,55,0])cube([200,1,40], center=true);
}
} else if (render==2) {
//translate(-pcb_center) pcb();
//translate([0,0,pcb_size.z]) color("beige") mat();
//case_top();
case_bottom();
//battery_lid();
} else if (render==3) {
button("R");
translate([10,10,0]) button("C");
}
module button(txt) {
difference() {
union() {
cylinder(h=8, d=btn_d-tolerance*2);
cylinder(h=2, d=btn_d+case_th*2);
translate([-(case_th-tolerance*2)/2,0,0]) cube([(case_th-tolerance*2),btn_d/2+case_th,5]);
}
translate([0,0,-1]) cylinder(h=3.5, d=4.5);
translate([-3,-3,7.5])linear_extrude(height = 1) {
text(txt, font = "Liberation Sans:style=Bold",size=6);
}
}
}
module pcb() {
difference() {
cube(pcb_size);
for (pos=holes) {
translate(pos+[0,0,-1]) cylinder(h=pcb_size.z+2, d=3.2);
}
}
//Eink
translate(eink_pcb_offset) color("white") cube(eink_size+[0,0,pcb_size.z]);
//eink visible area
translate(eink_pcb_offset+eink_vis_offset) color("magenta") cube(eink_vis_size+[0,0,pcb_size.z+eink_size.z+0.1]);
translate(bat_plus_pos-[bat_term_size.x/2, bat_term_size.y/2, bat_term_size.z]) cube(bat_term_size);
translate(bat_min_pos-[bat_term_size.x/2, bat_term_size.y/2, bat_term_size.z]) cube(bat_term_size);
translate(bat_plus_pos-[-2, 0, bat_dia/2]) rotate([0,90,0]) cylinder(d=bat_dia, h=100);
}
module chamfered_cutout(size) {
difference() {
translate([0,0,size.z/2]) cube(size+[size.z*2,size.z*2,tolerance*2], center=true);
translate([-size.x/2,-size.y,0]) rotate([0,-45,0]) translate([-size.z*2,0,0]) cube([size.z*2, size.y*2, size.z*2]);
translate([size.x/2,-size.y,0]) rotate([0,45,0]) translate([0,0,-tolerance*2])cube([size.z*2, size.y*2, size.z*2]);
translate([-size.x,-size.y/2,0]) rotate([45,0,0]) translate([0,-size.z*2,0])cube([size.x*2, size.z*2, size.z*2]);
translate([-size.x,size.y/2,0]) rotate([-45,0,0]) translate([0,0,-tolerance*2])cube([size.x*2, size.z*2, size.z*2]);
}
}
//note: assuming the eink visible middle is at [0,0] and the top of the pcb at z=0
module mat() {
difference() {
translate([0,0,mat_size.z/2]) cube(mat_size, center=true);
for (pos=holes) {
translate(-pcb_center+pos+[0,0,-1]) cylinder(h=mat_size.z+2, d=screw_insert_post_d+tolerance*2);
}
//beveled hole
translate([0,0,eink_size.z-tolerance])chamfered_cutout(eink_vis_size+[mat_vis_extra*2,mat_vis_extra*2,mat_size.z]);
//eink plate hole
translate(-pcb_center+eink_pcb_offset-[1, 1, tolerance]) cube(eink_size+[1, 1, tolerance]*2);
//cutouts for battery contact solder things
translate(-pcb_center+bat_plus_pos-[bat_term_stickout.x/2, bat_term_stickout.y/2, pcb_size.z]) cube(bat_term_stickout+[0,0,pcb_size.z]);
translate(-pcb_center+bat_min_pos-[bat_term_stickout.x/2, bat_term_stickout.y/2, tolerance]) cube(bat_term_stickout+[0,0,tolerance]);
}
}
module case_top() {
difference() {
intersection() {
case();
translate([-100,-100,-case_th+tolerance*2]) cube([200,200,20]);
}
//lip
translate([-case_size.x/2+case_th/2-tolerance/2, -case_size.y/2+case_th/2-tolerance/2, -case_size.z+tolerance*2]) cube(case_size-[case_th-tolerance,case_th-tolerance,0]);
}
}
module case_bot_fancy(pullback) {
edge_off=case_th*2;
h=bat_dia+case_th*2;
difference() {
//square case
translate([0,0,bat_dia/2+case_th-pullback]) cube([case_size.x-pullback*2,case_size.y-pullback*2,bat_dia+edge_off], center=true);
//beveled main
translate([-100, pcb_center.y-bat_plus_pos.y+bat_dia/2, h-pullback]) rotate([-5,0,0]) cube([200,200,20]);
//beveled at battery
translate([-100, pcb_center.y-bat_plus_pos.y-bat_dia/2, h-pullback]) rotate([30,0,0]) translate([0,-200,0]) cube([200,200,20]);
//beveled edges
translate([case_size.x/2,-case_size.y/2-tolerance,edge_off-pullback]) rotate([0,50,0]) translate([-100,-tolerance*2,0]) cube([100,case_size.y+tolerance*4,case_size.z,]);
translate([-case_size.x/2,case_size.y/2-tolerance,edge_off-pullback]) rotate([0,50,180]) translate([-100,-tolerance*2,0]) cube([100,case_size.y+tolerance*4,case_size.z,]);
//lip
if (pullback==0) difference() {
translate([0,0,case_th/2+tolerance/2]) cube([case_size.x,case_size.y,case_th+tolerance], center=true);
translate([0,0,case_th/2+tolerance/2]) cube([case_size.x-case_th,case_size.y-case_th,case_th+tolerance], center=true);
}
}
}
module battery_lid_cutout(pullback) {
translate(-pcb_center) {
translate([bat_plus_pos.x-bat_term_size.x/2-pullback,bat_min_pos.y-bat_dia/2-pullback,-20+tolerance*(case_th-pullback)]) cube((bat_min_pos-bat_plus_pos)+[bat_term_size.x+pullback*2,50,20]);
}
}
module battery_lid() {
difference() {
intersection() {
difference() {
//outer case
rotate([180,0,0]) case_bot_fancy(0);
//cut out screwholes
for (pos=holes) {
translate(-pcb_center+pos+[0,0,-50-case_th]) cylinder(h=50, d=screw_hole_d);
translate(-pcb_center+pos+[0,0,-50+tolerance]) cylinder(h=50, d=screw_thread_hole_d);
}
}
//cut out battery lid itself and half of the inner wall
battery_lid_cutout(case_th/2-tolerance);
}
difference() {
rotate([180,0,0]) case_bot_fancy(case_th/2-tolerance);
battery_lid_cutout(-tolerance);
}
difference() {
//inner case
rotate([180,0,0]) case_bot_fancy(case_th);
//cut out screwholes
for (pos=holes) {
translate(-pcb_center+pos+[0,0,-50]) cylinder(h=50, d=screw_hole_d+case_th*2);
}
translate(-pcb_center+[bat_plus_pos.x-bat_term_size.x,bat_min_pos.y+bat_dia/2,-20+tolerance]) cube((bat_min_pos-bat_plus_pos)+[bat_term_size.x*2,case_th,20]);
}
}
}
module support(pullback) {
translate(support_pos) rotate(-support_angle+[270,0,0]) translate([0,0,-pullback]) cylinder(h=90,d=support_d+pullback*2);
}
//support(0);
module case_bottom() {
difference() {
union() {
difference() {
//outer case
rotate([180,0,0]) case_bot_fancy(0);
//cut out screwholes
for (pos=holes) {
translate(-pcb_center+pos+[0,0,-50-case_th]) cylinder(h=50, d=screw_hole_d);
translate(-pcb_center+pos+[0,0,-50+tolerance]) cylinder(h=50, d=screw_thread_hole_d);
}
//cut out battery lid hole and half of the inner wall
battery_lid_cutout(case_th/2);
support(0);
}
//add other half of the inner wall
difference() {
intersection() {
rotate([180,0,0]) case_bot_fancy(case_th/2);
battery_lid_cutout(case_th);
}
battery_lid_cutout(0);
}
}
difference() {
//inner case
rotate([180,0,0]) case_bot_fancy(case_th);
//cut out screwholes
for (pos=holes) {
translate(-pcb_center+pos+[0,0,-50]) cylinder(h=50, d=screw_hole_d+case_th*2);
}
battery_lid_cutout(case_th);
hull() {
support(case_th);
translate([0,0,-10]) support(case_th);
}
for (pos=btns) {
translate(pos-pcb_center-[0,0,20]) difference() {
//button hole
cylinder(d=btn_d+case_th*2, h=10);
//slot
rotate([0,0,-90]) translate([0,-case_th/2,0]) cube([btn_d+case_th*3, case_th,11]);
}
}
//support in center of case back
translate([0,0,-20]) cylinder(h=20,d=8);
}
for (pos=btns) {
translate(pos-pcb_center-[0,0,20]) cylinder(d=btn_d+tolerance*2, h=20);
}
}
}
module case() {
hpos=pcb_size.z+mat_size.z+case_th+tolerance;
difference() {
union(){
difference() {
//outer cube
translate([0,0,-case_size.z/2+hpos]) cube(case_size, center=true);
//inner cube cutout
translate([0,0,-case_size.z/2+hpos]) cube(case_size-[case_th, case_th, case_th]*2, center=true);
}
for (pos=holes) {
translate(-pcb_center+pos+[0,0,pcb_size.z]) cylinder(h=screw_insert_h, d=screw_insert_post_d);
}
}
//mat cutout
translate([0,0,hpos-case_th-tolerance*2]) chamfered_cutout(mat_cutout+[0,0,case_th+tolerance*2]);
for (pos=holes) {
translate(-pcb_center+pos+[0,0,pcb_size.z-tolerance]) cylinder(h=screw_insert_h+tolerance, d=screw_insert_d);
}
}
}

5
firmware/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
build
sdkconfig
sdkconfig.old
managed_components
dependencies.lock

6
firmware/CMakeLists.txt Normal file
View file

@ -0,0 +1,6 @@
# The following lines of boilerplate have to be in your project's CMakeLists
# in this exact order for cmake to work correctly
cmake_minimum_required(VERSION 3.5)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(picframe)

@ -0,0 +1 @@
Subproject commit e855f6949de7a2038a50addae05f4f4b7bcaf204

View file

@ -0,0 +1,5 @@
set(srcs "main.c" "epd.c" "sync.c" "io.c")
idf_component_register(SRCS ${srcs}
INCLUDE_DIRS "."
EMBED_FILES icons.bmp)

View file

@ -0,0 +1,10 @@
menu "Photoframe Configuration"
config PHOTOFRAME_BASE_URL
string "Base URL"
default "http://example.com/"
help
Base URL. Points to a http (not https) URL where the epd-info.php etc files can be found.
Note that this MUST end with a / character!
endmenu

238
firmware/main/epd.c Normal file
View file

@ -0,0 +1,238 @@
/*
* ----------------------------------------------------------------------------
* "THE BEER-WARE LICENSE" (Revision 42):
* Jeroen Domburg <jeroen@spritesmods.com> wrote this file. As long as you retain
* this notice you can do whatever you want with this stuff. If we meet some day,
* and you think this stuff is worth it, you can buy me a beer in return.
* ----------------------------------------------------------------------------
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "driver/spi_master.h"
#include "driver/gpio.h"
#include "esp_timer.h"
#ifdef HSPI_HOST
//Waveshare ESP32 board
#define EPD_HOST HSPI_HOST
#define PIN_NUM_BUSY 25
#define PIN_NUM_MOSI 14
#define PIN_NUM_CLK 13
#define PIN_NUM_CS 15
#define PIN_NUM_RST 26
#define PIN_NUM_DC 27
#else
//Actual picframe_epd board.
#define EPD_HOST SPI2_HOST
#define PIN_NUM_BUSY 4
#define PIN_NUM_MOSI 9
#define PIN_NUM_CLK 8
#define PIN_NUM_CS 7
#define PIN_NUM_RST 5
#define PIN_NUM_DC 6
#endif
//To speed up transfers, every SPI transfer sends a bunch of lines. This define specifies how many. More means more memory use,
//but less overhead for setting up / finishing transfers. Make sure 240 is dividable by this.
#define PARALLEL_LINES 16
static const char *TAG="epd";
/*
The EPD needs a bunch of command/argument values to be initialized. They are stored in this struct.
*/
typedef struct {
uint8_t cmd;
uint8_t data[16];
uint8_t databytes; //No of data in data; bit 7 = delay after set; 0xFF = end of cmds.
} epd_init_cmd_t;
#define INIT_DATA_WAIT 0x80
static const epd_init_cmd_t epd_init_cmds[]={
{0x00, {0xef, 0x08}, 2},
{0x01, {0x37, 0x00, 0x23, 0x23}, 4},
{0x03, {0x00}, 1},
{0x06, {0xC7, 0xC7, 0x1D}, 3},
{0x30, {0x39}, 1},
{0x41, {0x00}, 1},
{0x50, {0x37}, 1},
{0x60, {0x22}, 1},
{0x61, {0x02, 0x58, 0x01, 0xC0}, 4},
{0xE3, {0xAA}, 1 | INIT_DATA_WAIT},
{0x50, {0x37}, 1},
{0, {0}, 0xFF}
};
/* Send a command to the EPD. Uses spi_device_polling_transmit, which waits
* until the transfer is complete.
*/
static void epd_cmd(spi_device_handle_t spi, const uint8_t cmd) {
esp_err_t ret;
spi_transaction_t t;
memset(&t, 0, sizeof(t)); //Zero out the transaction
t.length=8; //Command is 8 bits
t.tx_buffer=&cmd; //The data is the cmd itself
t.user=(void*)0; //D/C needs to be set to 0
ret=spi_device_polling_transmit(spi, &t); //Transmit!
assert(ret==ESP_OK); //Should have had no issues.
}
/* Send data to the EPD. Uses spi_device_polling_transmit, which waits until the
* transfer is complete.
*/
static void epd_data(spi_device_handle_t spi, const uint8_t *data, int len) {
esp_err_t ret;
spi_transaction_t t;
if (len==0) return; //no need to send anything
memset(&t, 0, sizeof(t)); //Zero out the transaction
t.length=len*8; //Len is in bytes, transaction length is in bits.
t.tx_buffer=data; //Data
t.user=(void*)1; //D/C needs to be set to 1
ret=spi_device_polling_transmit(spi, &t); //Transmit!
assert(ret==ESP_OK); //Should have had no issues.
}
//This function is called (in irq context!) just before a transmission starts. It will
//set the D/C line to the value indicated in the user field.
static void epd_spi_pre_transfer_callback(spi_transaction_t *t) {
int dc=(int)t->user;
gpio_set_level(PIN_NUM_DC, dc);
}
//Wait for the EPD to not be busy anymore
static void wait_busy(int val, int timeout_ms) {
int64_t tout=esp_timer_get_time()+timeout_ms*1000;
while(gpio_get_level(PIN_NUM_BUSY)!=val) {
vTaskDelay(2);
if (esp_timer_get_time()>tout) {
ESP_LOGE(TAG, "Timeout on waiting for busy to go %s!", val?"high":"low");
return;
}
}
}
//Initialize the display
static void epd_init(spi_device_handle_t spi) {
int cmd=0;
const gpio_config_t cfg[2]={
{
.pin_bit_mask=(1<<PIN_NUM_DC)|(1<<PIN_NUM_RST),
.mode=GPIO_MODE_OUTPUT
}, {
.pin_bit_mask=(1<<PIN_NUM_BUSY),
.mode=GPIO_MODE_INPUT,
.pull_up_en=GPIO_PULLUP_ENABLE
}
};
gpio_config(&cfg[0]);
gpio_config(&cfg[1]);
//Initialize non-SPI GPIOs
gpio_set_direction(PIN_NUM_DC, GPIO_MODE_OUTPUT);
gpio_set_direction(PIN_NUM_RST, GPIO_MODE_OUTPUT);
gpio_set_direction(PIN_NUM_BUSY, GPIO_MODE_INPUT);
//Reset the display
gpio_set_level(PIN_NUM_RST, 0);
vTaskDelay(pdMS_TO_TICKS(10));
gpio_set_level(PIN_NUM_RST, 1);
vTaskDelay(pdMS_TO_TICKS(100));
//wait for not busy
wait_busy(1, 1000);
//Send all the commands
while (epd_init_cmds[cmd].databytes!=0xff) {
epd_cmd(spi, epd_init_cmds[cmd].cmd);
uint8_t data[16];
memcpy(data, epd_init_cmds[cmd].data, 16);
epd_data(spi, data, epd_init_cmds[cmd].databytes&0x1F);
if (epd_init_cmds[cmd].databytes&0x80) {
vTaskDelay(pdMS_TO_TICKS(100));
}
cmd++;
}
}
extern const uint8_t icons_bmp_start[] asm("_binary_icons_bmp_start");
spi_device_handle_t spi;
void epd_send(const uint8_t *epddata, int icon) {
gpio_hold_dis(PIN_NUM_CS);
gpio_hold_dis(PIN_NUM_RST);
/* initialize epd */
esp_err_t ret;
spi_bus_config_t buscfg={
.miso_io_num=-1,
.mosi_io_num=PIN_NUM_MOSI,
.sclk_io_num=PIN_NUM_CLK,
.quadwp_io_num=-1,
.quadhd_io_num=-1,
.max_transfer_sz=PARALLEL_LINES*600/2+8
};
spi_device_interface_config_t devcfg={
.clock_speed_hz=1*1000*1000, //Clock out at 10 MHz
.mode=0, //SPI mode 0
.spics_io_num=PIN_NUM_CS, //CS pin
.queue_size=7, //We want to be able to queue 7 transactions at a time
.pre_cb=epd_spi_pre_transfer_callback, //Specify pre-transfer callback to handle D/C line
};
//Initialize the SPI bus
ret=spi_bus_initialize(EPD_HOST, &buscfg, SPI_DMA_CH_AUTO);
ESP_ERROR_CHECK(ret);
//Attach the EPD to the SPI bus
ret=spi_bus_add_device(EPD_HOST, &devcfg, &spi);
ESP_ERROR_CHECK(ret);
//Initialize the EPD
epd_init(spi);
epd_cmd(spi, 0x61);
uint8_t data[4]={0x02, 0x58, 0x01, 0xC0};
epd_data(spi, data, 4);
epd_cmd(spi, 0x10);
int bmp_pix_start=icons_bmp_start[0xa]+(icons_bmp_start[0xb]<<8); //actually header is 32-bit... care.
//ESP_LOGI(TAG, "bmp starts at 0x%X", bmp_pix_start);
for (int y=0; y<448; y++) {
uint8_t buf[300];
memcpy(buf, &epddata[y*300], 300);
if (icon!=0 && y<32) {
//the bmp is a file with 4-bit info. Each icon is 32x32 pixels (aka 32x16 bytes)
memcpy(buf, &icons_bmp_start[bmp_pix_start+y*16+(icon-1)*(16*32)], 16);
}
epd_data(spi, buf, 300);
}
epd_cmd(spi, 0x4);
wait_busy(1, 30000);
epd_cmd(spi, 0x12);
wait_busy(1, 30000);
epd_cmd(spi, 0x2);
wait_busy(1, 30000);
ESP_LOGI(TAG, "Displayed image.");
}
void epd_shutdown() {
//deep sleep
epd_cmd(spi, 0x7);
uint8_t sdata=0xA5;
epd_data(spi, &sdata, 1);
const gpio_config_t cfg={
.pin_bit_mask=(1<<PIN_NUM_DC)|(1<<PIN_NUM_RST)|(1<<PIN_NUM_MOSI)|(1<<PIN_NUM_CS)|(1<<PIN_NUM_CLK),
.mode=GPIO_MODE_OUTPUT
};
//not sure if CS survives deep sleep, as it's GPIO15... RST surely does.
gpio_set_level(PIN_NUM_CS, 0);
gpio_set_level(PIN_NUM_RST, 1);
gpio_config(&cfg);
gpio_hold_en(PIN_NUM_CS);
gpio_hold_en(PIN_NUM_RST);
}

9
firmware/main/epd.h Normal file
View file

@ -0,0 +1,9 @@
#define ICON_NONE 0
//note: icons are bottom to top in bmp
#define ICON_BAT_EMPTY 1
#define ICON_WIFI 2
#define ICON_SERVER 3
void epd_send(const uint8_t *epddata, int icon);
void epd_shutdown();

View file

@ -0,0 +1,20 @@
#pragma once
typedef struct __attribute__((packed)) {
uint32_t id;
uint64_t timestamp;
uint8_t unused[64-12];
} flash_image_hdr_t;
typedef struct __attribute__((packed)) {
flash_image_hdr_t hdr;
uint8_t data[600*448/2];
uint8_t padding[768-sizeof(flash_image_hdr_t)];
} flash_image_t;
#define IMG_SLOT_COUNT 10
#define IMG_SIZE_BYTES 0x21000
static inline int img_valid(const flash_image_hdr_t *img) {
return (img->id==0xfafa1a1a);
}

BIN
firmware/main/icons.bmp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1,17 @@
## IDF Component Manager Manifest File
dependencies:
espressif/mdns: "*"
## Required IDF version
idf:
version: ">=4.1.0"
# # Put list of dependencies here
# # For components maintained by Espressif:
# component: "~1.0.0"
# # For 3rd party components:
# username/component: ">=1.0.0,<2.0.0"
# username2/component2:
# version: "~1.0.0"
# # For transient dependencies `public` flag can be set.
# # `public` flag doesn't have an effect dependencies of the `main` component.
# # All dependencies of `main` are public by default.
# public: true

77
firmware/main/io.c Normal file
View file

@ -0,0 +1,77 @@
//Handles the simple IO things, like the button and battery measuring
/*
* ----------------------------------------------------------------------------
* "THE BEER-WARE LICENSE" (Revision 42):
* Jeroen Domburg <jeroen@spritesmods.com> wrote this file. As long as you retain
* this notice you can do whatever you want with this stuff. If we meet some day,
* and you think this stuff is worth it, you can buy me a beer in return.
* ----------------------------------------------------------------------------
*/
#include <esp_adc/adc_oneshot.h>
#include <esp_adc/adc_cali_scheme.h>
#include <driver/gpio.h>
#include <esp_timer.h>
#define PIN_NUM_BTN 10
static adc_oneshot_unit_handle_t adc1_handle;
static adc_cali_handle_t cal_handle;
//For the battery, we record the minimum voltage. Given that WiFi startup loads the battery,
//this gives a better indication of the state of the thing.
int min_bat=9999;
void adc_callback(void *arg) {
int raw, mv;
ESP_ERROR_CHECK(adc_oneshot_read(adc1_handle, ADC_CHANNEL_0, &raw));
adc_cali_raw_to_voltage(cal_handle, raw, &mv);
if (min_bat>mv) min_bat=mv;
}
void io_init() {
const gpio_config_t gpio_cfg={
.pin_bit_mask=(1<<PIN_NUM_BTN),
.mode=GPIO_MODE_INPUT,
.pull_up_en=GPIO_PULLUP_ENABLE
};
gpio_config(&gpio_cfg);
esp_timer_create_args_t config={
.callback=adc_callback,
.name="adc",
.skip_unhandled_events=true
};
adc_oneshot_unit_init_cfg_t adc1_cfg = {
.unit_id = ADC_UNIT_1,
.ulp_mode = false,
};
ESP_ERROR_CHECK(adc_oneshot_new_unit(&adc1_cfg, &adc1_handle));
adc_oneshot_chan_cfg_t chan_cfg = {
.bitwidth = ADC_BITWIDTH_DEFAULT,
.atten = ADC_ATTEN_DB_11,
};
ESP_ERROR_CHECK(adc_oneshot_config_channel(adc1_handle, ADC_CHANNEL_0, &chan_cfg));
adc_cali_curve_fitting_config_t cali_config = {
.unit_id = ADC_UNIT_1,
.atten = ADC_ATTEN_DB_11,
.bitwidth = ADC_BITWIDTH_DEFAULT,
};
ESP_ERROR_CHECK(adc_cali_create_scheme_curve_fitting(&cali_config, &cal_handle));
adc_callback(NULL); //first callback is manual
esp_timer_handle_t handle;
esp_timer_create(&config, &handle);
esp_timer_start_periodic(handle, 50*1000);
}
int io_get_btn() {
return !gpio_get_level(PIN_NUM_BTN);
}
int io_get_battery_mv() {
return min_bat;
}

3
firmware/main/io.h Normal file
View file

@ -0,0 +1,3 @@
void io_init();
int io_get_btn();
int io_get_battery_mv();

224
firmware/main/main.c Normal file
View file

@ -0,0 +1,224 @@
/*
* ----------------------------------------------------------------------------
* "THE BEER-WARE LICENSE" (Revision 42):
* Jeroen Domburg <jeroen@spritesmods.com> wrote this file. As long as you retain
* this notice you can do whatever you want with this stuff. If we meet some day,
* and you think this stuff is worth it, you can buy me a beer in return.
* ----------------------------------------------------------------------------
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_system.h"
#include "esp_timer.h"
#include "esp_log.h"
#include "driver/gpio.h"
#include "esp_wifi.h"
#include "lwip/ip_addr.h"
#include "wifi_manager.h"
#include <assert.h>
#include "esp_partition.h"
#include "esp_http_client.h"
#include "nvs.h"
#include "epd.h"
#include "epd_flash_image.h"
#include "esp_sleep.h"
#include "esp_timer.h"
#include "sync.h"
#include "io.h"
#include "nvs_flash.h"
#include "esp_ota_ops.h"
static const char *TAG="main";
SemaphoreHandle_t connect_sema;
void cb_connection_ok(void *pvParameter){
ip_event_got_ip_t* param = (ip_event_got_ip_t*)pvParameter;
/* transform IP to human readable string */
char str_ip[16];
esp_ip4addr_ntoa(&param->ip_info.ip, str_ip, IP4ADDR_STRLEN_MAX);
ESP_LOGI(TAG, "I have a connection and my IP is %s!", str_ip);
xSemaphoreGive(connect_sema);
}
void shutdown_callback(void *arg) {
ESP_LOGW(TAG, "Alive for too long! Sleeping.");
esp_sleep_enable_timer_wakeup(60*60*1000ULL*1000ULL);
esp_deep_sleep_start();
}
void app_main(void) {
_Static_assert((sizeof(flash_image_t) == IMG_SIZE_BYTES), "flash_image_t not right size");
connect_sema=xSemaphoreCreateBinary();
io_init();
int icon=ICON_NONE;
//make sure we shut down after 10 mins guaranteed
esp_timer_create_args_t config={
.callback=shutdown_callback,
.name="shutdown",
.skip_unhandled_events=true
};
esp_timer_handle_t handle;
esp_timer_create(&config, &handle);
esp_timer_start_periodic(handle, 10*60*1000ULL*1000ULL);
//Initialize NVS
esp_err_t err = nvs_flash_init();
if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
//NVS partition was truncated and needs to be erased.
ESP_ERROR_CHECK(nvs_flash_erase());
err = nvs_flash_init(); //Retry nvs_flash_init
}
ESP_ERROR_CHECK(err);
//get nvs handle
nvs_handle_t nvs;
nvs_open("epd", NVS_READWRITE, &nvs);
//map image partition, we'll need it later
const flash_image_t *images=NULL;
spi_flash_mmap_handle_t mmap_handle;
const esp_partition_t *part=esp_partition_find_first(123, 0, NULL);
esp_partition_mmap(part, 0, IMG_SIZE_BYTES*IMG_SLOT_COUNT, SPI_FLASH_MMAP_DATA, (const void**)&images, &mmap_handle);
//see if we still have enough juice to go online
int bat=io_get_battery_mv();
int32_t bat_empty_thr=2250;
ESP_LOGI(TAG, "Battery at %d mV before going online...", bat);
if (bat<bat_empty_thr) {
icon=ICON_BAT_EMPTY;
} else {
//start the wifi manager
wifi_manager_start();
wifi_manager_set_callback(WM_EVENT_STA_GOT_IP, &cb_connection_ok);
if (io_get_btn()) {
vTaskDelay(pdMS_TO_TICKS(200));
wifi_manager_send_message(WM_ORDER_DISCONNECT_STA, NULL);
vTaskDelay(pdMS_TO_TICKS(200));
wifi_manager_send_message(WM_ORDER_START_AP, NULL);
vTaskDelay(pdMS_TO_TICKS(200));
wifi_manager_send_message(WM_ORDER_START_HTTP_SERVER, NULL);
vTaskDelay(pdMS_TO_TICKS(200));
wifi_manager_send_message(WM_ORDER_START_DNS_SERVICE, NULL);
}
//wait for connection
ESP_LOGI(TAG, "Waiting for connection...x");
int can_connect=xSemaphoreTake(connect_sema, pdMS_TO_TICKS(30*1000));
int bat=io_get_battery_mv();
ESP_LOGI(TAG, "Battery at %d mV after WiFi init", bat);
if (bat<bat_empty_thr) {
icon=ICON_BAT_EMPTY;
} else {
if (can_connect) {
//We have a WiFi connection.
err=picframe_sync(images, part);
if (err==ESP_OK) esp_ota_mark_app_valid_cancel_rollback();
if (err!=ESP_OK) icon=ICON_SERVER;
} else {
icon=ICON_WIFI;
//No connection, mayhaps we're in config mode?
//If so, wait for a few minutes for the user to do its thing in the
//wifi manager UI. We break out either when we have a sta connection or
//if the mode is not apsta/ap anymore.
int wifi_timeout_sec=3*60;
do {
wifi_mode_t mode;
esp_wifi_get_mode(&mode);
if (mode==WIFI_MODE_STA) break;
can_connect=xSemaphoreTake(connect_sema, pdMS_TO_TICKS(1*1000));
if (can_connect) break;
wifi_timeout_sec--;
if (wifi_timeout_sec==0) break;
ESP_LOGI(TAG, "Delaying shutdown as we're in AP/APSTA mode...");
} while(1);
if (can_connect) {
err=picframe_sync(images, part);
if (err==ESP_OK) esp_ota_mark_app_valid_cancel_rollback();
if (err!=ESP_OK) icon=ICON_SERVER; else icon=ICON_NONE;
}
}
}
//Assume we're entirely done with WiFi.
wifi_manager_destroy();
vTaskDelay(pdMS_TO_TICKS(200)); //needed?
esp_wifi_stop();
}
//Grab cur_img and img_shows data from NVS
int16_t curr_img[IMG_SLOT_COUNT];
int16_t img_shows[IMG_SLOT_COUNT]={0};
for (int i=0; i<IMG_SLOT_COUNT; i++) curr_img[i]=-1; //default to invalid
size_t len=IMG_SLOT_COUNT*sizeof(uint16_t);
nvs_get_blob(nvs, "curr_img", curr_img, &len);
len=IMG_SLOT_COUNT*sizeof(uint16_t);
nvs_get_blob(nvs, "img_shows", img_shows, &len);
//Find the image that is valid with the highest ID and lowest count
int img=0;
for (int i=1; i<IMG_SLOT_COUNT; i++) {
if (curr_img[i]==-1) continue; //invalid
if (img_shows[i]<img_shows[img]) {
img=i;
} else if (img_shows[i]==img_shows[img]) {
if (curr_img[i]>curr_img[img]) img=i;
}
}
//Show image we found, if any
if (curr_img[img]!=-1) {
img_shows[img]++;
nvs_set_blob(nvs, "img_shows", img_shows, IMG_SLOT_COUNT*sizeof(uint16_t));
ESP_LOGI(TAG, "Displaying img id %d from slot %d", curr_img[img], img);
epd_send(images[img].data, icon);
epd_shutdown();
}
ESP_LOGI(TAG, "Going to sleep.");
//Set timezone
char tz[32];
len=sizeof(tz);
if (nvs_get_str(nvs, "tz", tz, &len)==ESP_OK) {
setenv("TZ", tz, 1);
tzset();
}
//Figure out when to wake.
int32_t updhour=0;
nvs_get_i32(nvs, "upd_hour", &updhour);
struct tm tm;
time_t now=time(NULL);
localtime_r(&now, &tm);
ESP_LOGI(TAG, "Now is %d:%02d, sleeping till %ld:00", tm.tm_hour, tm.tm_min, updhour);
tm.tm_hour=updhour;
tm.tm_min=0;
time_t wake=mktime(&tm);
int64_t sleep_sec=wake-time(NULL);
//Make sure this is in the future. We can wake up a bit before the wake time, and without
//this, it would sleep only a short while until the actual wake time.
while (sleep_sec<(10*60)) sleep_sec+=(24*60*60);
if (icon==ICON_BAT_EMPTY) {
//Sleep forever.
ESP_LOGI(TAG, "Low battery, so sleeping forever.");
esp_deep_sleep_start();
}
//Zzzzz....
ESP_LOGI(TAG, "Sleeping %lld sec", sleep_sec);
esp_sleep_enable_timer_wakeup(sleep_sec*1000ULL*1000ULL);
vTaskDelay(pdMS_TO_TICKS(100));
esp_deep_sleep_start();
}

301
firmware/main/sync.c Normal file
View file

@ -0,0 +1,301 @@
/*
This code syncs the state of the picture frame (firmware, images in memory, config) to
what the server thinks it should be.
* ----------------------------------------------------------------------------
* "THE BEER-WARE LICENSE" (Revision 42):
* Jeroen Domburg <jeroen@spritesmods.com> wrote this file. As long as you retain
* this notice you can do whatever you want with this stuff. If we meet some day,
* and you think this stuff is worth it, you can buy me a beer in return.
* ----------------------------------------------------------------------------
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_system.h"
#include "esp_timer.h"
#include "esp_log.h"
#include "esp_wifi.h"
#include <assert.h>
#include "esp_partition.h"
#include "esp_http_client.h"
#include "nvs.h"
#include "esp_mac.h"
#include "cJSON.h"
#include "esp_check.h"
#include "esp_flash_partitions.h"
#include "esp_ota_ops.h"
#include "esp_tls_crypto.h"
#include "mbedtls/base64.h"
#include "sync.h"
#include "io.h"
#include "sdkconfig.h"
static const char *TAG="sync";
#define BASE_URL CONFIG_PHOTOFRAME_BASE_URL
#define INFO_PATH "epd-info.php"
#define IMG_PATH "epd-img.php"
#define CHECKFW_OK 0
#define CHECKFW_NEED_UPDATE 1
#define CHECKFW_ERR 2
static void get_app_sha(uint8_t *sha, int for_ota_part) {
const esp_partition_t *runpart;
if (!for_ota_part) {
runpart=esp_ota_get_running_partition();
} else {
runpart=esp_ota_get_next_update_partition(NULL);
}
esp_app_desc_t runappinfo;
esp_ota_get_partition_description(runpart, &runappinfo);
memcpy(sha, runappinfo.app_elf_sha256, 32);
}
static int check_fw_update(cJSON *json) {
char sha[32];
cJSON *js_fwsha=cJSON_GetObjectItem(json, "fw_sha");
if (!js_fwsha) return CHECKFW_ERR;
const char *sha_txt=cJSON_GetStringValue(js_fwsha);
if (!sha_txt) return CHECKFW_ERR;
size_t olen;
mbedtls_base64_decode((unsigned char*)sha, 32, &olen, (const unsigned char*)sha_txt, strlen(sha_txt));
if (olen!=32) return CHECKFW_ERR;
uint8_t cur_sha[32];
get_app_sha(cur_sha, 0);
if (memcmp(cur_sha, sha, 32)==0) {
ESP_LOGI(TAG, "Firmware still up to date.");
return CHECKFW_OK;
} else {
ESP_LOGI(TAG, "Firmware needs update.");
get_app_sha(cur_sha, 1);
if (memcmp(cur_sha, sha, 32)==0) {
ESP_LOGI(TAG, "...but the update was previously attempted and failed");
return CHECKFW_OK;
}
return CHECKFW_NEED_UPDATE;
}
}
static void do_fw_update(esp_http_client_handle_t http) {
esp_ota_handle_t ota;
const esp_partition_t *runpart=esp_ota_get_running_partition();
const esp_partition_t *updpart=esp_ota_get_next_update_partition(runpart);
esp_err_t err=esp_ota_begin(updpart, OTA_SIZE_UNKNOWN, &ota);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Could not initialize ota: %s", esp_err_to_name(err));
goto err_ota;
}
//Data read/write loop
char buf[1024];
while(1) {
int len=esp_http_client_read(http, buf, sizeof(buf));
if (len==0) {
if (esp_http_client_is_complete_data_received(http)) {
err=esp_ota_end(ota);
if (err != ESP_OK) {
ESP_LOGE(TAG, "OTA finalize failed: %s", esp_err_to_name(err));
goto err;
}
break;
} else {
ESP_LOGE(TAG, "HTTP reading image failed");
goto err_ota;
}
}
err=esp_ota_write(ota, buf, len);
if (err != ESP_OK) {
ESP_LOGE(TAG, "OTA write of len %d failed: %s", len, esp_err_to_name(err));
goto err_ota;
}
}
err=esp_ota_set_boot_partition(updpart);
if (err != ESP_OK) {
ESP_LOGE(TAG, "OTA set boot part failed: %s", esp_err_to_name(err));
goto err;
}
ESP_LOGW(TAG, "Update succesful! Booting into new app version.");
esp_restart();
while(1); //never reached
err_ota:
esp_ota_abort(ota);
err:
}
const char *json_get_string(cJSON *json, const char *name) {
cJSON *jsnode=cJSON_GetObjectItem(json, name);
if (!jsnode) return NULL;
const char *ret=cJSON_GetStringValue(jsnode);
return ret;
}
esp_err_t picframe_sync(const flash_image_t *images, const esp_partition_t *part) {
esp_err_t ret=ESP_OK;
const esp_http_client_config_t config={
.url=BASE_URL,
.timeout_ms=16000,
};
esp_http_client_handle_t http=esp_http_client_init(&config);
ESP_GOTO_ON_FALSE(http, ESP_ERR_NO_MEM, err_client_alloc, TAG, "couldn't init http client");
//Generate info retrieve URL
unsigned char mac[6];
char url[192];
esp_base_mac_addr_get(mac);
int bat_pwr=io_get_battery_mv();
uint8_t cur_sha[32];
get_app_sha(cur_sha, 0);
char cur_sha_text[16];
for (int i=0; i<8; i++) sprintf(&cur_sha_text[i*2], "%02X", cur_sha[i]);
sprintf(url, "%s%s?mac=%02X%02X%02X%02X%02X%02X&bat=%d&fw=%s", BASE_URL, INFO_PATH,
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5], bat_pwr, cur_sha_text);
//Fetch info
esp_err_t err;
err=esp_http_client_set_url(http, url);
ESP_GOTO_ON_ERROR(err, err_http, TAG, "esp_http_client_url to info url failed");
err=esp_http_client_open(http, 0);
ESP_GOTO_ON_ERROR(err, err_http, TAG, "esp_http_client_open for info url failed");
esp_http_client_fetch_headers(http); //note: error ignored
char resp[512];
size_t len=esp_http_client_read(http, resp, sizeof(resp));
ESP_GOTO_ON_FALSE(len>0, ESP_FAIL, err_http, TAG, "couldn't read info url");
err=esp_http_client_close(http);
ESP_GOTO_ON_ERROR(err, err_http, TAG, "esp_http_client_open for info url failed");
//Parse info
cJSON *json=cJSON_Parse(resp);
ESP_GOTO_ON_FALSE(json!=NULL, ESP_FAIL, err_http, TAG, "couldn't parse info");
//First, see if there's a software update.
int needs_update=check_fw_update(json);
if (needs_update==CHECKFW_NEED_UPDATE) {
const char *upd_path=json_get_string(json, "fw_upd");
ESP_GOTO_ON_FALSE(upd_path!=NULL, ESP_FAIL, err_http, TAG, "couldn't parse json from info");
sprintf(url, "%s%s", BASE_URL, upd_path);
ESP_LOGI(TAG, "Doing OTA from %s", url);
err=esp_http_client_set_url(http, url);
ESP_GOTO_ON_ERROR(err, err_httpjs, TAG, "esp_http_client_url to update url failed");
err=esp_http_client_open(http, 0);
ESP_GOTO_ON_ERROR(err, err_httpjs, TAG, "esp_http_client_open for update url failed");
esp_http_client_fetch_headers(http);
do_fw_update(http);
err=esp_http_client_close(http);
ESP_GOTO_ON_ERROR(err, err_httpjs, TAG, "esp_http_client_close for update url failed");
}
//Next, save the things we received to flash
nvs_handle_t nvs;
nvs_open("epd", NVS_READWRITE, &nvs);
const char *tz=json_get_string(json, "tz");
if (tz) nvs_set_str(nvs, "tz", tz);
cJSON *j_updhour=cJSON_GetObjectItem(json, "update_hour");
if (j_updhour) {
int32_t updhour=cJSON_GetNumberValue(j_updhour);
nvs_set_i32(nvs, "upd_hour", updhour);
}
//Set time. We set timezone in the main app from the nvs value.
cJSON *js_time=cJSON_GetObjectItem(json, "time");
if (js_time && tz) {
setenv("TZ", "GMT+0", 1);
tzset();
//set time
struct timeval tv={0};
tv.tv_sec=cJSON_GetNumberValue(js_time);
settimeofday(&tv, NULL);
ESP_LOGI(TAG, "Time set.");
}
int16_t curr_img[IMG_SLOT_COUNT];
int16_t server_img[IMG_SLOT_COUNT];
int16_t img_shows[IMG_SLOT_COUNT]={0};
for (int i=0; i<IMG_SLOT_COUNT; i++) {
curr_img[i]=-1; //default to invalid
server_img[i]=-1;
}
len=IMG_SLOT_COUNT*sizeof(uint16_t);
nvs_get_blob(nvs, "curr_img", curr_img, &len);
len=IMG_SLOT_COUNT*sizeof(uint16_t);
nvs_get_blob(nvs, "img_shows", img_shows, &len);
//Parse info we got from server
cJSON *js_ids=cJSON_GetObjectItem(json, "images");
ESP_GOTO_ON_FALSE(js_ids, ESP_FAIL, err_httpjs, TAG, "no image array in info");
for (int i=0; i<IMG_SLOT_COUNT; i++) {
cJSON *js_id=cJSON_GetArrayItem(js_ids, i);
if (!js_id) continue;
server_img[i]=cJSON_GetNumberValue(js_id);
}
//See if there's anything we need to download
for (int i=0; i<IMG_SLOT_COUNT; i++) {
int found=0;
for (int j=0; j<IMG_SLOT_COUNT; j++) {
if (server_img[i]==curr_img[j]) found=1;
}
if (found) {
ESP_LOGI(TAG, "Image ID %d: already have that", server_img[i]);
} else {
//Need to find a slot to download this to. We can use a slot that contains an
//image that is stale, as in, not on the list the server gave us.
int download_slot=-1;
for (int j=0; j<IMG_SLOT_COUNT; j++) {
int slot_available=1;
for (int k=0; k<IMG_SLOT_COUNT; k++) {
if (curr_img[j]==server_img[k]) slot_available=0;
}
if (slot_available) {
download_slot=j;
break;
}
}
//Mark slot as invalid, in case the download fails
curr_img[download_slot]=-1; //invalid
nvs_set_blob(nvs, "curr_img", curr_img, IMG_SLOT_COUNT*sizeof(uint16_t));
//Download image to flash partition.
ESP_LOGI(TAG, "Image ID %d: need to download to slot %d, overwriting image id %d", server_img[i], download_slot, curr_img[download_slot]);
esp_partition_erase_range(part, download_slot*IMG_SIZE_BYTES, IMG_SIZE_BYTES);
sprintf(url, "%s%s?id=%d", BASE_URL, IMG_PATH, server_img[i]);
err=esp_http_client_set_url(http, url);
ESP_GOTO_ON_ERROR(err, err_httpjs, TAG, "esp_http_client_url to image url failed");
err=esp_http_client_open(http, 0);
ESP_GOTO_ON_ERROR(err, err_httpjs, TAG, "esp_http_client_open for image url failed");
esp_http_client_fetch_headers(http);
char buf[1024];
int p=download_slot*IMG_SIZE_BYTES;
int len;
int recved=0;
while ((len=esp_http_client_read(http, buf, sizeof(buf)))>0) {
esp_partition_write(part, p, buf, len);
p+=len;
recved+=len;
}
ESP_GOTO_ON_FALSE(len>=0, ESP_FAIL, err_http, TAG, "couldn't read image");
esp_http_client_close(http);
if (recved<sizeof(flash_image_hdr_t)+(600*448/2)) {
//Not sure what happened here... download succeeded but was too small. Server error?
ESP_LOGW(TAG, "Image data too short. Not marking image as valid.");
} else {
//update curr_img to reflect download
curr_img[download_slot]=server_img[i];
img_shows[download_slot]=0;
nvs_set_blob(nvs, "curr_img", curr_img, IMG_SLOT_COUNT*sizeof(uint16_t));
nvs_set_blob(nvs, "img_shows", img_shows, IMG_SLOT_COUNT*sizeof(uint16_t));
}
}
}
ESP_LOGI(TAG, "Sync done.");
err_httpjs:
cJSON_Delete(json);
err_http:
esp_http_client_cleanup(http);
err_client_alloc:
return ret;
}

3
firmware/main/sync.h Normal file
View file

@ -0,0 +1,3 @@
#include "epd_flash_image.h"
esp_err_t picframe_sync(const flash_image_t *images, const esp_partition_t *part);

7
firmware/partitions.csv Normal file
View file

@ -0,0 +1,7 @@
# Name, Type, SubType, Offset, Size, Flags
# Note: if you have increased the bootloader size, make sure to update the offsets to avoid overlap
nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, 0xe000, 0x2000,
ota_0, app, ota_0, 0x10000, 0x150000
ota_1, app, ota_1, , 0x150000
images, 123, 00, , 0x150000
1 # Name, Type, SubType, Offset, Size, Flags
2 # Note: if you have increased the bootloader size, make sure to update the offsets to avoid overlap
3 nvs, data, nvs, 0x9000, 0x5000,
4 otadata, data, ota, 0xe000, 0x2000,
5 ota_0, app, ota_0, 0x10000, 0x150000
6 ota_1, app, ota_1, , 0x150000
7 images, 123, 00, , 0x150000

View file

@ -0,0 +1,20 @@
CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PHOTOFRAME_BASE_URL="http://example.com/please_configure_this_url_in_menuconfig/"
CONFIG_HTTPD_MAX_REQ_HDR_LEN=2048
CONFIG_ESP_SLEEP_POWER_DOWN_FLASH=y
# CONFIG_ESP_SLEEP_FLASH_LEAKAGE_WORKAROUND is not set
CONFIG_ESP_PHY_REDUCE_TX_POWER=y
CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_80=y
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
CONFIG_ESP_TASK_WDT_PANIC=y
CONFIG_ESP_TASK_WDT_TIMEOUT_S=10
CONFIG_ESP_PANIC_HANDLER_IRAM=y
# CONFIG_ESP_WIFI_STA_DISCONNECTED_PM_ENABLE is not set
# CONFIG_ESP_PROTOCOMM_SUPPORT_SECURITY_VERSION_2 is not set
CONFIG_WIFI_PROV_BLE_FORCE_ENCRYPTION=y
CONFIG_WIFI_MANAGER_MAX_RETRY_START_AP=9999999
CONFIG_DEFAULT_AP_SSID="photoframe"
CONFIG_DEFAULT_AP_PASSWORD=""

19
notes.txt Normal file
View file

@ -0,0 +1,19 @@
For PCB:
XR1151 - 800'ish mA, 65uA quiescent -> 3.5 year
ESP32C3 - need to test if works with same code, otherwise NP
Article:
Color Floyd-Steinberg is a PITA. Difference colors? LAB colors, euclidian thingy. That sucks,
pink white and blue-ish white is the same difference as red and blue. Initial modification:
multiply A and B differences (=hue difference) with sin(L). This makes those differences less
important. Great results. But WtF is up with the shitty 'official' way? Seems there's multiple
ways: latest and greatest is E2000. Actually not that much different than my hacky solution.
Non-linear color space: one pixel of (0,0,0) and one of (64,64,64) don't look the same
(are not as bright) as two of (32,32,32). Luckily (linearized) RGB works.
Stucki dithering?
https://mathematica.stackexchange.com/questions/7483/how-to-calculate-mix-of-4-colors-defined-in-cielab-lab-model

2
pcb/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
gerber
einkpicframe-backups

5423
pcb/einkpicframe.kicad_pcb Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,77 @@
{
"board": {
"active_layer": 0,
"active_layer_preset": "",
"auto_track_width": false,
"hidden_netclasses": [],
"hidden_nets": [],
"high_contrast_mode": 0,
"net_color_mode": 1,
"opacity": {
"images": 0.6,
"pads": 1.0,
"tracks": 1.0,
"vias": 1.0,
"zones": 0.6
},
"ratsnest_display_mode": 0,
"selection_filter": {
"dimensions": true,
"footprints": true,
"graphics": true,
"keepouts": true,
"lockedItems": true,
"otherItems": true,
"pads": true,
"text": true,
"tracks": true,
"vias": true,
"zones": true
},
"visible_items": [
0,
1,
2,
3,
4,
5,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30,
32,
33,
34,
35,
36
],
"visible_layers": "ffcffff_ffffffff",
"zone_display_mode": 0
},
"meta": {
"filename": "einkpicframe.kicad_prl",
"version": 3
},
"project": {
"files": []
}
}

552
pcb/einkpicframe.kicad_pro Normal file
View file

@ -0,0 +1,552 @@
{
"board": {
"3dviewports": [],
"design_settings": {
"defaults": {
"board_outline_line_width": 0.09999999999999999,
"copper_line_width": 0.19999999999999998,
"copper_text_italic": false,
"copper_text_size_h": 1.5,
"copper_text_size_v": 1.5,
"copper_text_thickness": 0.3,
"copper_text_upright": false,
"courtyard_line_width": 0.049999999999999996,
"dimension_precision": 4,
"dimension_units": 3,
"dimensions": {
"arrow_length": 1270000,
"extension_offset": 500000,
"keep_text_aligned": true,
"suppress_zeroes": false,
"text_position": 0,
"units_format": 1
},
"fab_line_width": 0.09999999999999999,
"fab_text_italic": false,
"fab_text_size_h": 1.0,
"fab_text_size_v": 1.0,
"fab_text_thickness": 0.15,
"fab_text_upright": false,
"other_line_width": 0.15,
"other_text_italic": false,
"other_text_size_h": 1.0,
"other_text_size_v": 1.0,
"other_text_thickness": 0.15,
"other_text_upright": false,
"pads": {
"drill": 0.762,
"height": 1.524,
"width": 1.524
},
"silk_line_width": 0.15,
"silk_text_italic": false,
"silk_text_size_h": 1.0,
"silk_text_size_v": 1.0,
"silk_text_thickness": 0.15,
"silk_text_upright": false,
"zones": {
"45_degree_only": false,
"min_clearance": 0.508
}
},
"diff_pair_dimensions": [
{
"gap": 0.0,
"via_gap": 0.0,
"width": 0.0
}
],
"drc_exclusions": [],
"meta": {
"version": 2
},
"rule_severities": {
"annular_width": "error",
"clearance": "error",
"connection_width": "warning",
"copper_edge_clearance": "error",
"copper_sliver": "warning",
"courtyards_overlap": "error",
"diff_pair_gap_out_of_range": "error",
"diff_pair_uncoupled_length_too_long": "error",
"drill_out_of_range": "error",
"duplicate_footprints": "warning",
"extra_footprint": "warning",
"footprint": "error",
"footprint_type_mismatch": "error",
"hole_clearance": "error",
"hole_near_hole": "error",
"invalid_outline": "error",
"isolated_copper": "warning",
"item_on_disabled_layer": "error",
"items_not_allowed": "error",
"length_out_of_range": "error",
"lib_footprint_issues": "warning",
"lib_footprint_mismatch": "warning",
"malformed_courtyard": "error",
"microvia_drill_out_of_range": "error",
"missing_courtyard": "ignore",
"missing_footprint": "warning",
"net_conflict": "warning",
"npth_inside_courtyard": "ignore",
"padstack": "error",
"pth_inside_courtyard": "ignore",
"shorting_items": "error",
"silk_edge_clearance": "warning",
"silk_over_copper": "warning",
"silk_overlap": "warning",
"skew_out_of_range": "error",
"solder_mask_bridge": "error",
"starved_thermal": "error",
"text_height": "warning",
"text_thickness": "warning",
"through_hole_pad_without_hole": "error",
"too_many_vias": "error",
"track_dangling": "warning",
"track_width": "error",
"tracks_crossing": "error",
"unconnected_items": "error",
"unresolved_variable": "error",
"via_dangling": "warning",
"zones_intersect": "error"
},
"rules": {
"allow_blind_buried_vias": false,
"allow_microvias": false,
"max_error": 0.005,
"min_clearance": 0.0,
"min_connection": 0.0,
"min_copper_edge_clearance": 0.0,
"min_hole_clearance": 0.25,
"min_hole_to_hole": 0.25,
"min_microvia_diameter": 0.19999999999999998,
"min_microvia_drill": 0.09999999999999999,
"min_resolved_spokes": 2,
"min_silk_clearance": 0.0,
"min_text_height": 0.7999999999999999,
"min_text_thickness": 0.08,
"min_through_hole_diameter": 0.3,
"min_track_width": 0.19999999999999998,
"min_via_annular_width": 0.049999999999999996,
"min_via_diameter": 0.39999999999999997,
"solder_mask_clearance": 0.0,
"solder_mask_min_width": 0.0,
"solder_mask_to_copper_clearance": 0.0,
"use_height_for_length_calcs": true
},
"teardrop_options": [
{
"td_allow_use_two_tracks": true,
"td_curve_segcount": 5,
"td_on_pad_in_zone": false,
"td_onpadsmd": true,
"td_onroundshapesonly": false,
"td_ontrackend": false,
"td_onviapad": true
}
],
"teardrop_parameters": [
{
"td_curve_segcount": 0,
"td_height_ratio": 1.0,
"td_length_ratio": 0.5,
"td_maxheight": 2.0,
"td_maxlen": 1.0,
"td_target_name": "td_round_shape",
"td_width_to_size_filter_ratio": 0.9
},
{
"td_curve_segcount": 0,
"td_height_ratio": 1.0,
"td_length_ratio": 0.5,
"td_maxheight": 2.0,
"td_maxlen": 1.0,
"td_target_name": "td_rect_shape",
"td_width_to_size_filter_ratio": 0.9
},
{
"td_curve_segcount": 0,
"td_height_ratio": 1.0,
"td_length_ratio": 0.5,
"td_maxheight": 2.0,
"td_maxlen": 1.0,
"td_target_name": "td_track_end",
"td_width_to_size_filter_ratio": 0.9
}
],
"track_widths": [
0.0,
0.25,
0.45
],
"via_dimensions": [
{
"diameter": 0.0,
"drill": 0.0
},
{
"diameter": 0.8,
"drill": 0.4
}
],
"zones_allow_external_fillets": false,
"zones_use_no_outline": true
},
"layer_presets": [],
"viewports": []
},
"boards": [],
"cvpcb": {
"equivalence_files": []
},
"erc": {
"erc_exclusions": [],
"meta": {
"version": 0
},
"pin_map": [
[
0,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
2
],
[
0,
2,
0,
1,
0,
0,
1,
0,
2,
2,
2,
2
],
[
0,
0,
0,
0,
0,
0,
1,
0,
1,
0,
1,
2
],
[
0,
1,
0,
0,
0,
0,
1,
1,
2,
1,
1,
2
],
[
0,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
2
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
2
],
[
1,
1,
1,
1,
1,
0,
1,
1,
1,
1,
1,
2
],
[
0,
0,
0,
1,
0,
0,
1,
0,
0,
0,
0,
2
],
[
0,
2,
1,
2,
0,
0,
1,
0,
2,
2,
2,
2
],
[
0,
2,
0,
1,
0,
0,
1,
0,
2,
0,
0,
2
],
[
0,
2,
1,
1,
0,
0,
1,
0,
2,
0,
0,
2
],
[
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2
]
],
"rule_severities": {
"bus_definition_conflict": "error",
"bus_entry_needed": "error",
"bus_to_bus_conflict": "error",
"bus_to_net_conflict": "error",
"conflicting_netclasses": "error",
"different_unit_footprint": "error",
"different_unit_net": "error",
"duplicate_reference": "error",
"duplicate_sheet_names": "error",
"endpoint_off_grid": "warning",
"extra_units": "error",
"global_label_dangling": "warning",
"hier_label_mismatch": "error",
"label_dangling": "error",
"lib_symbol_issues": "warning",
"missing_bidi_pin": "warning",
"missing_input_pin": "warning",
"missing_power_pin": "error",
"missing_unit": "warning",
"multiple_net_names": "warning",
"net_not_bus_member": "warning",
"no_connect_connected": "warning",
"no_connect_dangling": "warning",
"pin_not_connected": "error",
"pin_not_driven": "error",
"pin_to_pin": "warning",
"power_pin_not_driven": "error",
"similar_labels": "warning",
"simulation_model_issue": "error",
"unannotated": "error",
"unit_value_mismatch": "error",
"unresolved_variable": "error",
"wire_dangling": "error"
}
},
"libraries": {
"pinned_footprint_libs": [],
"pinned_symbol_libs": []
},
"meta": {
"filename": "einkpicframe.kicad_pro",
"version": 1
},
"net_settings": {
"classes": [
{
"bus_width": 12,
"clearance": 0.2,
"diff_pair_gap": 0.25,
"diff_pair_via_gap": 0.25,
"diff_pair_width": 0.2,
"line_style": 0,
"microvia_diameter": 0.3,
"microvia_drill": 0.1,
"name": "Default",
"pcb_color": "rgba(0, 0, 0, 0.000)",
"schematic_color": "rgba(0, 0, 0, 0.000)",
"track_width": 0.25,
"via_diameter": 0.8,
"via_drill": 0.4,
"wire_width": 6
},
{
"bus_width": 12,
"clearance": 0.2,
"diff_pair_gap": 0.25,
"diff_pair_via_gap": 0.25,
"diff_pair_width": 0.2,
"line_style": 0,
"microvia_diameter": 0.3,
"microvia_drill": 0.1,
"name": "Pwr",
"pcb_color": "rgba(0, 0, 0, 0.000)",
"schematic_color": "rgba(0, 0, 0, 0.000)",
"track_width": 0.45,
"via_diameter": 1.2,
"via_drill": 0.6,
"wire_width": 6
}
],
"meta": {
"version": 3
},
"net_colors": null,
"netclass_assignments": null,
"netclass_patterns": [
{
"netclass": "Pwr",
"pattern": "+3V3"
},
{
"netclass": "Pwr",
"pattern": "/VBAT"
},
{
"netclass": "Pwr",
"pattern": "/VBAT_F"
},
{
"netclass": "Pwr",
"pattern": "/VBAT_FP"
},
{
"netclass": "Pwr",
"pattern": "/VBAT_IND"
},
{
"netclass": "Pwr",
"pattern": "GND"
}
]
},
"pcbnew": {
"last_paths": {
"gencad": "",
"idf": "",
"netlist": "",
"specctra_dsn": "",
"step": "",
"vrml": ""
},
"page_layout_descr_file": ""
},
"schematic": {
"annotate_start_num": 0,
"drawing": {
"dashed_lines_dash_length_ratio": 12.0,
"dashed_lines_gap_length_ratio": 3.0,
"default_line_thickness": 6.0,
"default_text_size": 50.0,
"field_names": [],
"intersheets_ref_own_page": false,
"intersheets_ref_prefix": "",
"intersheets_ref_short": false,
"intersheets_ref_show": false,
"intersheets_ref_suffix": "",
"junction_size_choice": 3,
"label_size_ratio": 0.375,
"pin_symbol_size": 25.0,
"text_offset_ratio": 0.15
},
"legacy_lib_dir": "",
"legacy_lib_list": [],
"meta": {
"version": 1
},
"net_format_name": "",
"ngspice": {
"fix_include_paths": true,
"fix_passive_vals": false,
"meta": {
"version": 0
},
"model_mode": 0,
"workbook_filename": ""
},
"page_layout_descr_file": "",
"plot_directory": "",
"spice_adjust_passive_values": false,
"spice_current_sheet_as_root": false,
"spice_external_command": "spice \"%I\"",
"spice_model_current_sheet_as_root": true,
"spice_save_all_currents": false,
"spice_save_all_voltages": false,
"subpart_first_id": 65,
"subpart_id_separator": 0
},
"sheets": [
[
"6e57f210-b0e1-4f15-9b02-871a474fe8e8",
""
]
],
"text_variables": {}
}

5027
pcb/einkpicframe.kicad_sch Normal file

File diff suppressed because it is too large Load diff

86017
pcb/fp-info-cache Normal file

File diff suppressed because it is too large Load diff

1
www/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
config.php

17
www/README.md Normal file
View file

@ -0,0 +1,17 @@
This requires a MySQL or MariaDB database to work.
How to do this:
- Make sure your server is running a MySQL/Mariadb database you have access to.
- Create user 'epd' in the database. Assign a password.
- Copy config.php.example to config.php. Edit the $pass variable to be the actual password.
- Create database 'epd', grant user 'epd' all permissions
- Create the structure:
mysql -u epd -p epd < create-database.sql
This also needs a image -> EPD binary program that is written in C to work. This
needs to be compiled. To do so:
- Make sure gcc and make are installed on the host
- Install libgd-dev (the development package for libgd)
- cd conv; make
- Make sure php is allowed to run unix executables

11
www/config.php.example Normal file
View file

@ -0,0 +1,11 @@
<?php
//Database configuration. Replace with your actual values
//Database name
$db="epd";
//Username/password
$username="epd";
$pass="mypassword";
?>

5
www/conv/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
conv
*.o
*.jpg
*.png
*.bin

6
www/conv/Makefile Normal file
View file

@ -0,0 +1,6 @@
CFLAGS=-ggdb -O2
LDFLAGS=-lgd -lm
conv: conv.o
$(CC) -o $@ $^ $(LDFLAGS)

370
www/conv/conv.c Normal file
View file

@ -0,0 +1,370 @@
/*
* ----------------------------------------------------------------------------
* "THE BEER-WARE LICENSE" (Revision 42):
* Jeroen Domburg <jeroen@spritesmods.com> wrote this file. As long as you retain
* this notice you can do whatever you want with this stuff. If we meet some day,
* and you think this stuff is worth it, you can buy me a beer in return.
* ----------------------------------------------------------------------------
*/
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <time.h>
#include "gd.h"
#include <math.h>
#include <assert.h>
#include <string.h>
#define EPD_W 600
#define EPD_H 448
#define EPD_UPSIDE_DOWN 1
//This loads a png/jpg file and if needed converts it to truecolor, crops the center to the
//EPD aspect ratio and scales to EPD_W/EPD_H.
gdImagePtr load_scaled(char *filename) {
FILE *f;
f=fopen(filename, "r");
if (f==NULL) {
perror(filename);
return NULL;
}
//We check the first 8 bytes of the file to check if it's PNG.
char pnghdr[8]={0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A};
char buf[8];
fread(buf, 8, 1, f);
rewind(f);
//If match, we load it as PNG, if not we load it as JPEG.
gdImagePtr oim;
if (memcmp(pnghdr, buf, 8)==0) {
oim=gdImageCreateFromPng(f);
} else {
oim=gdImageCreateFromJpegEx(f, 1);
}
if (oim==NULL) {
return NULL;
}
gdImagePtr nim=NULL;
if (gdImageSY(oim)==EPD_H && gdImageSX(oim)==EPD_W && gdImageTrueColor(oim)) {
//no scaling needed
nim=oim;
} else {
//Need scaling and/or converting to truecolor
nim=gdImageCreateTrueColor(EPD_W, EPD_H);
if (nim==NULL) {
gdImageDestroy(oim);
return NULL;
}
int bgnd = gdImageColorAllocate(nim, 255,255,255);
gdImageFilledRectangle(nim, 0, 0, EPD_W, EPD_H, bgnd);
int nw=EPD_W;
int nh=(gdImageSY(oim)*EPD_W)/gdImageSX(oim);
if (nh>EPD_H) {
nh=EPD_H;
nw=(gdImageSX(oim)*EPD_H)/gdImageSY(oim);
}
gdImageCopyResampled(nim, oim, (EPD_W-nw)/2, (EPD_H-nh)/2,
0, 0, nw, nh, gdImageSX(oim), gdImageSY(oim));
gdImageDestroy(oim);
}
return nim;
}
//The two typedefs define what the epd binary image looks like.
typedef struct __attribute__((packed)) {
uint32_t id;
uint64_t timestamp;
uint8_t unused[64-12];
} flash_image_hdr_t;
typedef struct __attribute__((packed)) {
flash_image_hdr_t hdr;
uint8_t data[600*448/2];
uint8_t padding[768-sizeof(flash_image_hdr_t)];
} flash_image_t;
//Convert SRGB to linear.
//note: takes srgb [0..1], outputs linear rgb [0..1]
float gamma_linear(float in) {
return (in > 0.04045) ? pow((in + 0.055) / (1.0 + 0.055), 2.4) : (in / 12.92);
}
//Converts a RGB float to a CIE-LAB float
//note: takes linearized RGB [0..1], outputs LAB [0..1]
void rgb_to_lab(float *rgb, float *lab) {
float var_R = rgb[0] * 100;
float var_G = rgb[1] * 100;
float var_B = rgb[2] * 100;
if (var_R>100) var_R=100;
if (var_G>100) var_G=100;
if (var_B>100) var_B=100;
if (var_R<0) var_R=0;
if (var_G<0) var_G=0;
if (var_B<0) var_B=0;
//Observer. = 2°, Illuminant = D65
float inX = var_R * 0.4124f + var_G * 0.3576f + var_B * 0.1805f;
float inY = var_R * 0.2126f + var_G * 0.7152f + var_B * 0.0722f;
float inZ = var_R * 0.0193f + var_G * 0.1192f + var_B * 0.9505f;
float var_X = (inX / 95.047);
float var_Y = (inY / 100.0);
float var_Z = (inZ / 108.883);
if ( var_X > 0.008856 )
var_X = powf(var_X , ( 1.0f/3 ));
else
var_X = ( 7.787 * var_X ) + ( 16.0f/116 );
if ( var_Y > 0.008856 )
var_Y = powf(var_Y , ( 1.0f/3 ));
else
var_Y = ( 7.787 * var_Y ) + ( 16.0f/116 );
if ( var_Z > 0.008856 )
var_Z = powf(var_Z , ( 1.0f/3 ));
else
var_Z = ( 7.787 * var_Z ) + ( 16.0f/116 );
lab[0] = ( 116 * var_Y ) - 16;
lab[1] = 500 * ( var_X - var_Y );
lab[2] = 200 * ( var_Y - var_Z );
}
//Returns the input value clamped between min and max
float clamp(float val, float min, float max) {
if (val<min) val=min;
if (val>max) val=max;
return val;
}
//Given an array of [rgb] floats, adds the difference diff to the
//pixel at [x,y], with i indicating the color (0=r, 1=g, 2=b)
void dist_diff(float *pixels, int x, int y, int i, float dif) {
if (x<0 || x>=EPD_W) return;
if (y<0 || y>=EPD_H) return;
float new_val=pixels[(x+y*EPD_W)*3+i]+dif;
new_val=clamp(new_val, 0, 1);
pixels[(x+y*EPD_W)*3+i]=new_val;
}
static inline float deg2rad(float deg) {
return (2 * M_PI * deg) / 360.0;
}
static inline float rad2deg(float rad) {
return 360.0 * rad / (2 * M_PI);
}
//This, when fed two RGB values in range [0..1], returns the deltaE00 difference between the two.
//The higher the return value, the more perceptually different the two RGB values are.
float col_diff(float *rgb1, float *rgb2) {
float lab1[3], lab2[3];
rgb_to_lab(rgb1, lab1);
rgb_to_lab(rgb2, lab2);
//deltaE00 code stolen from https://github.com/hamada147/IsThisColourSimilar/blob/master/Colour.js
// Start Equation
// Equation exist on the following URL http://www.brucelindbloom.com/index.html?Eqn_DeltaE_CIE2000.html
float avgL=(lab1[0]+lab2[0])/2;
float c1 = sqrtf(powf(lab1[1], 2) + powf(lab1[2], 2));
float c2 = sqrtf(powf(lab2[1], 2) + powf(lab2[2], 2));
float avgC = (c1 + c2) / 2;
float g = (1 - sqrtf(powf(avgC, 7) / (powf(avgC, 7) + powf(25, 7)))) / 2;
float a1p = lab1[1] * (1 + g);
float a2p = lab2[1] * (1 + g);
float c1p = sqrtf(powf(a1p, 2) + powf(lab1[2], 2));
float c2p = sqrtf(powf(a2p, 2) + powf(lab2[2], 2));
float avgCp = (c1p + c2p) / 2;
float h1p = rad2deg(atan2f(lab1[2], a1p));
if (h1p < 0) h1p = h1p + 360;
float h2p = rad2deg(atan2f(lab2[2], a2p));
if (h2p < 0) h2p = h2p + 360;
float avghp = fabsf(h1p - h2p) > 180 ? (h1p + h2p + 360) / 2 : (h1p + h2p) / 2;
float t = 1 - 0.17 * cosf(deg2rad(avghp - 30)) + 0.24 * cosf(deg2rad(2 * avghp)) + 0.32 * cosf(deg2rad(3 * avghp + 6)) - 0.2 * cosf(deg2rad(4 * avghp - 63));
float deltahp = h2p - h1p;
if (fabsf(deltahp) > 180) {
if (h2p <= h1p) {
deltahp += 360;
} else {
deltahp -= 360;
}
}
float deltalp = lab2[0] - lab1[0];
float deltacp = c2p - c1p;
deltahp = 2 * sqrtf(c1p * c2p) * sinf(deg2rad(deltahp) / 2);
float sl = 1 + ((0.015 * powf(avgL - 50, 2)) / sqrtf(20 + powf(avgL - 50, 2)));
float sc = 1 + 0.045 * avgCp;
float sh = 1 + 0.015 * avgCp * t;
float deltaro = 30 * expf(-(powf((avghp - 275) / 25, 2)));
float rc = 2 * sqrtf(powf(avgCp, 7) / (powf(avgCp, 7) + powf(25, 7)));
float rt = -rc * sinf(2 * deg2rad(deltaro));
float kl = 1;
float kc = 1;
float kh = 1;
float deltaE = sqrtf(powf(deltalp / (kl * sl), 2) + powf(deltacp / (kc * sc), 2) + powf(deltahp / (kh * sh), 2) + rt * (deltacp / (kc * sc)) * (deltahp / (kh * sh)));
return deltaE;
}
int main(int argc, char **argv) {
char *im_in="";
char *im_out="";
char *bin_out="";
int error=0;
//Find & parse command line arguments
for (int i=1; i<argc; i++) {
if (strcmp(argv[i], "-p")==0 && i<argc-1) {
if (im_out[0]!=0) error=1;
i++;
im_out=argv[i];
} else if (strcmp(argv[i], "-o")==0 && i<argc-1) {
if (bin_out[0]!=0) error=1;
i++;
bin_out=argv[i];
} else {
if (im_in[0]!=0) error=1;
im_in=argv[i];
}
}
if (im_in[0]==0 || error) {
printf("Usage: %s [-o outfile.bin] [-p preview.png] infile.[jpg|png]\n", argv[0]);
printf("infile can be png or jpeg; if no outfile is given, output will go to stdout\n");
exit(error);
}
//Load image
gdImagePtr im=load_scaled(im_in);
if (!im) {
fprintf(stderr, "Could not load image %s\n", im_in);
exit(1);
}
flash_image_t *bin=calloc(sizeof(flash_image_t), 1);
assert(bin);
bin->hdr.id=0xfafa1a1a; //magic header
bin->hdr.timestamp=time(NULL);
//RGB colors as displayed on the screen
//These are calculated by grabbing a test pattern which shows all colors, taking a picture,
//then using an image editor to max out the levels for max contrast & saturation, then taking
//the linear (not SRGB) RGB values and putting them here.
float epd_colors[7][3]={ //rgb
{0,0,0},
{1,1,1},
{0.059, 0.329, 0.119},
{0.061, 0.147, 0.336},
{0.574, 0.066, 0.010},
{0.982, 0.756, 0.004},
{0.795, 0.255, 0.018},
};
//Convert float RGB colors to int colors for putting on the preview png
int epd_colors_int[7];
for (int i=0; i<7; i++) {
int c=0;
for (int j=0; j<3; j++) {
int pc=epd_colors[i][j]*255;
c=(c<<8)|pc;
}
epd_colors_int[i]=c;
}
//Convert image to an array of floats so we can Floyd-Steinberg without limiting ourselves
//to the range of ints
float *pixels=calloc(EPD_W*EPD_H*3, sizeof(float));
assert(pixels);
float *pixelp=pixels;
for (int y=0; y<EPD_H; y++) {
for (int x=0; x<EPD_W; x++) {
int c=gdImageGetPixel(im,x,y);
*pixelp++=gamma_linear(((c>>16)&0xff)/255.0);
*pixelp++=gamma_linear(((c>>8)&0xff)/255.0);
*pixelp++=gamma_linear(((c>>0)&0xff)/255.0);
}
}
//Create preview image
gdImagePtr tim=gdImageCreateTrueColor(EPD_W, EPD_H);
assert(tim);
for (int y=0; y<EPD_H; y++) {
int ob=0;
for (int x=0; x<EPD_W; x++) {
//Find closest color for this pixel from the palette the epd can display
int best=0;
float best_dif=999999999;
for (int i=0; i<7; i++) {
float d=col_diff(&pixels[(x+y*EPD_W)*3], epd_colors[i]);
if (d<best_dif) {
best_dif=d;
best=i;
}
}
//Distribute difference between chosen and ideal color using Floyd-Steinberg
for (int i=0; i<3; i++) {
float dif=pixels[(x+y*EPD_W)*3+i] - epd_colors[best][i];
dist_diff(pixels, x+1, y, i, (dif/16.0)*7.0);
dist_diff(pixels, x-1, y+1, i, (dif/16.0)*3.0);
dist_diff(pixels, x, y+1, i, (dif/16.0)*5.0);
dist_diff(pixels, x+1, y+1, i, (dif/16.0)*1.0);
}
// best=((x*14)/448)%7; //uncomment for test image
//Set byte in output EPD binary data
#if EPD_UPSIDE_DOWN
if (x&1) {
bin->data[((EPD_H-1-y)*EPD_W+(EPD_W-1-x))/2]=ob|(best<<4);
} else {
ob=best;
}
#else
if (x&1) {
bin->data[(y*EPD_W+x)/2]=(ob<<4)|best;
} else {
ob=best;
}
#endif
//Also set pixel in output
gdImageSetPixel(tim, x, y, epd_colors_int[best]);
}
}
if (im_out[0]) {
//Write preview image
FILE *of=fopen(im_out, "w");
if (!of) {
perror(im_out);
exit(1);
}
gdImagePng(tim, of);
fclose(of);
}
gdImageDestroy(tim);
if (bin_out[0]) {
//Write binary output to file
FILE *of=fopen(bin_out, "wb");
if (!of) {
perror(bin_out);
exit(1);
}
fwrite(bin, sizeof(flash_image_t)-sizeof(bin->padding), 1, of);
fclose(of);
} else {
//Write binary output to stdout
fwrite(bin, sizeof(flash_image_t)-sizeof(bin->padding), 1, stdout);
}
exit(0);
}

79
www/create-database.sql Normal file
View file

@ -0,0 +1,79 @@
-- MariaDB dump 10.19 Distrib 10.5.15-MariaDB, for debian-linux-gnu (x86_64)
--
-- Host: localhost Database: epd
-- ------------------------------------------------------
-- Server version 10.5.15-MariaDB-0+deb11u1
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!40101 SET NAMES utf8mb4 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
--
-- Table structure for table `checkins`
--
DROP TABLE IF EXISTS `checkins`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `checkins` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`device_id` int(11) NOT NULL,
`timestamp` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`battery_mv` int(11) NOT NULL,
`ip` text NOT NULL,
`fw` text NOT NULL,
PRIMARY KEY (`id`),
KEY `device_id` (`device_id`)
) ENGINE=InnoDB AUTO_INCREMENT=143 DEFAULT CHARSET=utf8mb4;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `devices`
--
DROP TABLE IF EXISTS `devices`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `devices` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`mac` tinytext NOT NULL,
`tz` tinytext NOT NULL,
`update_hour` int(11) NOT NULL,
`fw_upd` tinytext NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `images`
--
DROP TABLE IF EXISTS `images`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `images` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`timestamp` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`orig_name` text NOT NULL,
`epd_bin` mediumblob NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=88 DEFAULT CHARSET=utf8mb4;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2022-08-04 9:54:18

1
www/cropperjs Submodule

@ -0,0 +1 @@
Subproject commit 133b8b07d00a68a01063af2f57f0fab183050fcf

28
www/epd-img.php Normal file
View file

@ -0,0 +1,28 @@
<?php
/*
Return an EPD binary. If no argument, return latest EPD binary. If GET argument 'id' is given,
return EPD binary with that ID.
* ----------------------------------------------------------------------------
* "THE BEER-WARE LICENSE" (Revision 42):
* Jeroen Domburg <jeroen@spritesmods.com> wrote this file. As long as you retain
* this notice you can do whatever you want with this stuff. If we meet some day,
* and you think this stuff is worth it, you can buy me a beer in return.
* ----------------------------------------------------------------------------
*/
require("config.php");
$mysqli = mysqli_connect("localhost",$username, $pass, $db);
if (isset($_GET["id"])) {
$id=intval($_GET["id"]);
$result = $mysqli->query("SELECT epd_bin FROM images WHERE `id`='$id' ORDER BY timestamp DESC LIMIT 1");
} else {
//get latest
$result = $mysqli->query("SELECT epd_bin FROM images ORDER BY timestamp DESC LIMIT 1");
}
header("Content-Type: image/epd");
$row=$result->fetch_assoc();
echo $row["epd_bin"];
?>

92
www/epd-info.php Normal file
View file

@ -0,0 +1,92 @@
<?php
/*
Return JSON with the state we expect the EPD controller to be in: what firmware it's supposed
to have and what images are supposed to be in its memory. We also record the battery voltage.
Called using GET: epd-info.php?mac=01234567&bat=2901
* ----------------------------------------------------------------------------
* "THE BEER-WARE LICENSE" (Revision 42):
* Jeroen Domburg <jeroen@spritesmods.com> wrote this file. As long as you retain
* this notice you can do whatever you want with this stuff. If we meet some day,
* and you think this stuff is worth it, you can buy me a beer in return.
* ----------------------------------------------------------------------------
*/
require("config.php");
//This returns the app_info SHA hash. If the image is different, this is different.
//Makes the assumption the appinfo magic doesn't appear earlier in the image.
function get_app_image_data($file) {
$f=fopen(__DIR__."/".$file, "r");
if (!$f) return "";
$data=fread($f, 1024*4);
fclose($f);
$magicpos=strpos($data, "\x32\x54\xCD\xAB"); //0xABCD5432 little-endian
if (!$magicpos) return "";
$shapos=$magicpos+4+4+8+32+32+16+16+32;
return base64_encode(substr($data,$shapos,32));
}
$mysqli = mysqli_connect("localhost",$username, $pass, $db);
//Sanitize MAC
$mac="000000000000";
if (isset($_GET["mac"])) $mac=$_GET["mac"];
if (!preg_match("/^[0-9a-fA-F]{12}$/", $mac)) {
$mac="000000000000";
}
$fw="unknown";
if (isset($_GET["fw"])) $fw=$_GET["fw"];
//Find device based on MAC
$result = $mysqli->query("SELECT id,tz,fw_upd,update_hour FROM devices WHERE `mac`='$mac'");
$dev_info=$result->fetch_assoc();
if (!$dev_info) {
//No data... make a new one
$stmt = $mysqli->prepare("INSERT INTO devices (mac,tz,fw_upd,update_hour) VALUES (?,?,?,?)");
$tz="CST-8";
$fw_upd="picframe.bin";
$update_hour=3;
$stmt->bind_param("sssi", $mac, $tz, $fw_upd, $update_hour);
$stmt->execute() || die($stmt->error);
//re-run query to fetch the data... lazy, I know
$result = $mysqli->query("SELECT id,tz,fw_upd,update_hour FROM devices WHERE `mac`='$mac'");
$dev_info=$result->fetch_assoc();
}
//Insert checkin data into db
$stmt = $mysqli->prepare("INSERT INTO checkins (device_id,battery_mv,ip,fw) VALUES (?,?,?,?)");
$battery_mv=0;
$ip="";
$fw="unknown";
if (isset($_GET["fw"])) $fw=$_GET["fw"];
if (isset($_GET["bat"])) $battery_mv=$_GET["bat"];
if (isset($_SERVER['REMOTE_ADDR'])) $ip=$_SERVER['REMOTE_ADDR'];
$stmt->bind_param("iiss", $dev_info["id"], $battery_mv, $ip, $fw);
$stmt->execute() || die($stmt->error);
//Grab latest 10 images
$image_ids=array();
$result = $mysqli->query("SELECT id FROM images ORDER BY timestamp DESC LIMIT 10");
//Get IDs of last 10 images
for ($i=0; $i<10; $i++) {
$row=$result->fetch_assoc();
$image_ids[$i]=intval($row["id"]);
}
//Dump all info in json
$ret=array();
$ret["time"]=time();
$ret["fw_sha"]=get_app_image_data($dev_info["fw_upd"]);
$ret["fw_upd"]=$dev_info["fw_upd"];
$ret["tz"]=$dev_info["tz"];
$ret["update_hour"]=intval($dev_info["update_hour"]);
$ret["images"]=$image_ids;
//send
header("Content-Type: text/json");
echo json_encode($ret);
?>

104
www/index.html Normal file
View file

@ -0,0 +1,104 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Cropper.js</title>
<link rel="stylesheet" href="cropperjs/dist//cropper.css">
<style>
.container {
margin: 20px auto;
max-width: 640px;
max-height: 640px;
}
image {
width: 100%;
height: 100%;
}
result {
width=600px;
height=448px;
display=none;
}
</style>
</head>
<body>
<div class="container">
<input type="file" id="file">
<input type="submit" value="Store image" id="okbutton" style="display: none">
<div>
<img id="image" class="image" src="" alt="Picture">
</div>
<div id="result" class="result" src="" alt="Result">
</div>
<script src="cropperjs/dist/cropper.js"></script>
<script>
window.addEventListener('DOMContentLoaded', function () {
var file = document.querySelector("#file");
var okbutton = document.querySelector("#okbutton");
var image = document.querySelector("#image");
var cropper;
file.addEventListener('change', function() {
if (this.files && this.files[0]) {
okbutton.style.display="block";
file.style.display="none";
image.onload = () => {
image.width="640";
image.height="640";
URL.revokeObjectURL(image.src); // no longer needed, free memory
cropper = new Cropper(image, {
dragMode: 'move',
aspectRatio: 600/448,
autoCropArea: 0.8,
restore: false,
guides: false,
center: false,
highlight: false,
cropBoxMovable: false,
cropBoxResizable: false,
toggleDragModeOnDblclick: false,
rotatable: true,
});
}
image.src = URL.createObjectURL(this.files[0]); // set src to blob url
}
});
okbutton.addEventListener("click", function() {
var canvas = cropper.getCroppedCanvas({
width: 600,
height: 448,
});
canvas.toBlob(function (blob) {
var formData = new FormData();
formData.append('image', blob, 'image.jpg');
var xhr = new XMLHttpRequest();
xhr.upload.onprogress = function (e) {
var percent = '0';
var percentage = '0%';
if (e.lengthComputable) {
percent = Math.round((e.loaded / e.total) * 100);
okbutton.value=percent + '%';
}
}
xhr.onload = function () {
var res=document.querySelector("#result");
//eeuw
res.innerHTML='<img src="data:image/gif;base64,' + this.responseText + '">';
res.style.display="block";
image.style.display="none";
cropper.destroy();
okbutton.value="done";
}
xhr.onerror = function () {
okbutton.value="error";
}
xhr.open("POST", "upload.php");
xhr.send(formData);
});
});
});
</script>
</body>
</html>

41
www/upload.php Normal file
View file

@ -0,0 +1,41 @@
<?php
require("config.php");
//(id, timestamp, orig_name, epd_bin)
if (!isset($_FILES["image"])) {
header("Location: /index.html");
}
//system("/bin/cp \"".$_FILES["image"]["tmp_name"]."\" /tmp/img.png");
$pngfile=tempnam("/tmp","epd");
$convproc=popen(__DIR__."/conv/conv -p \"".$pngfile."\" \"".$_FILES["image"]["tmp_name"]."\"", "r");
$mysqli = mysqli_connect("localhost",$username, $pass, $db);
$stmt = $mysqli->prepare("INSERT INTO images (orig_name,epd_bin) VALUES (?,?)");
$orig_name="";
$null=0;
$stmt->bind_param("sb", $orig_name, $null);
while(!feof($convproc)) {
$stmt->send_long_data(1, fread($convproc, 1024));
}
$stmt->send_long_data(1, $bin);
$ret=pclose($convproc);
if ($ret!=0) {
unlink($pngfile);
exit(1);
}
$stmt->execute() || die($stmt->error);
//header("Content-Type: image/png");
//readfile($pngfile);
echo base64_encode(file_get_contents($pngfile));
unlink($pngfile);
?>