Skip to content

Waveshare 10.3" display #120

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Aug 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions backend/app/drivers/devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ def drivers_for_frame(frame: Frame) -> dict[str, Driver]:
else:
device_drivers = {"waveshare": waveshare, "spi": DRIVERS["spi"]}

if waveshare.variant == "EPD_10in3":
device_drivers["bootconfig"] = DRIVERS["bootConfig"]
device_drivers["bootconfig"].lines = [
"dtoverlay=spi0-0cs",
"#dtparam=spi=on"
]
if waveshare.variant == "EPD_13in3e":
device_drivers["bootconfig"] = DRIVERS["bootConfig"]
device_drivers["bootconfig"].lines = [
Expand Down
33 changes: 25 additions & 8 deletions backend/app/drivers/waveshare.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@ class WaveshareVariant:
width: Optional[int] = None
height: Optional[int] = None
init_function: Optional[str] = None
init_args: str = ""
clear_function: Optional[str] = None
clear_args: str = ""
sleep_function: Optional[str] = None
display_function: Optional[str] = None
display_arguments: Optional[list[str]] = None
init_returns_zero: bool = False
color_option: Literal["Unknown", "Black", "BlackWhiteRed", "BlackWhiteYellow", "FourGray", "SpectraSixColor", "SevenColor", "BlackWhiteYellowRed"] = "Unknown"
color_option: Literal["Unknown", "Black", "BlackWhiteRed", "BlackWhiteYellow", "FourGray", "SpectraSixColor", "SevenColor", "BlackWhiteYellowRed", "SixteenGray"] = "Unknown"

# Colors if we can't autodetect
VARIANT_COLORS = {
Expand Down Expand Up @@ -66,6 +67,8 @@ class WaveshareVariant:
"EPD_4in0e": "SpectraSixColor",
"EPD_7in3e": "SpectraSixColor",
"EPD_13in3e": "SpectraSixColor",

"EPD_10in3": "SixteenGray",
}

def get_variant_keys_for(folder: str) -> list[str]:
Expand All @@ -77,13 +80,20 @@ def get_variant_keys_for(folder: str) -> list[str]:
]

def get_variant_keys() -> list[str]:
return [*get_variant_keys_for("ePaper"), *get_variant_keys_for("epd12in48"), *get_variant_keys_for("epd13in3e")]
return [
*get_variant_keys_for("ePaper"),
*get_variant_keys_for("it8951"),
*get_variant_keys_for("epd12in48"),
*get_variant_keys_for("epd13in3e"),
]

def get_variant_folder(variant_key: str) -> str:
if variant_key in get_variant_keys_for("ePaper") :
return "ePaper"
elif variant_key == "EPD_13in3e":
return "epd13in3e"
elif variant_key == "EPD_10in3":
return "it8951"
else:
return "epd12in48"

Expand Down Expand Up @@ -160,13 +170,13 @@ def convert_waveshare_source(variant_key: Optional[str]) -> WaveshareVariant:
proc_name = line.split("*(")[0].split(" ")[1]
if proc_name.lower() == f"{variant.prefix}_Init".lower() and variant.init_function is None:
variant.init_function = proc_name
variant.init_returns_zero = "(): UBYTE" in line
variant.init_returns_zero = "): UBYTE" in line
if proc_name.lower() == f"{variant.prefix}_Init_4Gray".lower():
variant.init_function = proc_name
variant.init_returns_zero = "(): UBYTE" in line
variant.init_returns_zero = "): UBYTE" in line
if proc_name.lower() == f"{variant.prefix}_4Gray_Init".lower():
variant.init_function = proc_name
variant.init_returns_zero = "(): UBYTE" in line
variant.init_returns_zero = "): UBYTE" in line
if proc_name.lower() == f"{variant.prefix}_Clear".lower() and variant.clear_function is None:
variant.clear_function = proc_name
if "color: UBYTE" in line:
Expand All @@ -181,6 +191,9 @@ def convert_waveshare_source(variant_key: Optional[str]) -> WaveshareVariant:
variant.display_function = proc_name
variant.display_arguments = get_proc_arguments(line, variant_key)
# print("-> " + proc_name + "(" + (", ".join(variant.display_arguments)) + ") <-")
if (proc_name.lower() == f"{variant.prefix}_16Gray_Display".lower()):
variant.display_function = proc_name
variant.display_arguments = get_proc_arguments(line, variant_key)

if variant.display_arguments == ["Black"]:
variant.color_option = "Black"
Expand All @@ -197,6 +210,10 @@ def convert_waveshare_source(variant_key: Optional[str]) -> WaveshareVariant:
variant.color_option = "SevenColor"
elif variant.display_arguments == ["SpectraSixColor"]:
variant.color_option = "SpectraSixColor"
elif variant_key == "EPD_10in3":
variant.color_option = "SixteenGray"
variant.init_args = "self"
variant.init_returns_zero = True
else:
print(f"Unknown color: {variant_key} - {variant.display_function} -- {variant.display_arguments}" )

Expand All @@ -218,7 +235,7 @@ def write_waveshare_driver_nim(drivers: dict[str, Driver]) -> str:

import {variant_folder}/DEV_Config as waveshareConfig
import {variant_folder}/{variant.key} as waveshareDisplay
from ./types import ColorOption
import drivers/waveshare/types

let width* = waveshareDisplay.{variant.prefix}_WIDTH
let height* = waveshareDisplay.{variant.prefix}_HEIGHT
Expand All @@ -230,8 +247,8 @@ def write_waveshare_driver_nim(drivers: dict[str, Driver]) -> str:
let resp = waveshareConfig.DEV_Module_Init()
if resp != 0: raise newException(Exception, "Failed to initialize waveshare display")

proc start*() =
{'discard ' if variant.init_returns_zero else ''}waveshareDisplay.{variant.init_function}()
proc start*(self: Driver) =
{'discard ' if variant.init_returns_zero else ''}waveshareDisplay.{variant.init_function}({variant.init_args})

proc clear*() =
waveshareDisplay.{variant.clear_function}({variant.clear_args})
Expand Down
2 changes: 1 addition & 1 deletion backend/app/models/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ async def upload_font_assets(db: Session, redis: Redis, frame: Frame, assets_pat
remote_fonts = {a["path"]: int(a.get("size", 0)) for a in assets}
else:
command = f"find {assets_path}/fonts -type f -exec stat --format='%s %Y %n' {{}} +"
status, stdout, _ = await run_command(db, redis, frame, command)
status, stdout, _ = await run_command(db, redis, frame, command, log_output=False)
stdout_lines = stdout.splitlines()
remote_fonts = {}
for line in stdout_lines:
Expand Down
5 changes: 5 additions & 0 deletions backend/app/models/frame.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class Frame(Base):
width = mapped_column(Integer, nullable=True)
height = mapped_column(Integer, nullable=True)
device = mapped_column(String(256), nullable=True)
device_config = mapped_column(JSON, nullable=True)
color = mapped_column(String(256), nullable=True)
interval = mapped_column(Double, default=300)
metrics_interval = mapped_column(Double, default=60)
Expand Down Expand Up @@ -87,6 +88,7 @@ def to_dict(self):
'width': self.width,
'height': self.height,
'device': self.device,
'device_config': self.device_config,
'color': self.color,
'interval': self.interval,
'metrics_interval': self.metrics_interval,
Expand Down Expand Up @@ -239,6 +241,9 @@ def get_frame_json(db: Session, frame: Frame) -> dict:
"width": frame.width or 0,
"height": frame.height or 0,
"device": frame.device or "web_only",
"deviceConfig": {
**({"vcom": float(frame.device_config.get('vcom', '0'))} if frame.device_config and frame.device_config.get('vcom') else {})
},
"metricsInterval": frame.metrics_interval or 60.0,
"debug": frame.debug or False,
"scalingMode": frame.scaling_mode or "contain",
Expand Down
2 changes: 2 additions & 0 deletions backend/app/schemas/frames.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class FrameBase(BaseModel):
width: Optional[int]
height: Optional[int]
device: Optional[str]
device_config: Optional[Dict[str, Any]] = None
color: Optional[str]
interval: float
metrics_interval: float
Expand Down Expand Up @@ -89,6 +90,7 @@ class FrameUpdateRequest(BaseModel):
upload_fonts: Optional[str] = None
scaling_mode: Optional[str] = None
device: Optional[str] = None
device_config: Optional[Dict[str, Any]] = None
debug: Optional[bool] = None
reboot: Any = None
control_code: Any = None
Expand Down
9 changes: 8 additions & 1 deletion backend/app/tasks/_frame_deployer.py
Original file line number Diff line number Diff line change
Expand Up @@ -601,7 +601,12 @@ async def create_local_build_archive(
if waveshare := drivers.get('waveshare'):
if waveshare.variant:
variant_folder = get_variant_folder(waveshare.variant)
util_files = ["Debug.h", "DEV_Config.c", "DEV_Config.h"]

if variant_folder == "it8951":
util_files = ["DEV_Config.c", "DEV_Config.h"]
else:
util_files = ["Debug.h", "DEV_Config.c", "DEV_Config.h"]

for uf in util_files:
shutil.copy(
os.path.join(source_dir, "src", "drivers", "waveshare", variant_folder, uf),
Expand All @@ -616,6 +621,8 @@ async def create_local_build_archive(
]:
c_file = re.sub(r'[bc]', 'bc', waveshare.variant)
variant_files = [f"{waveshare.variant}.nim", f"{c_file}.c", f"{c_file}.h"]
elif waveshare.variant == "EPD_10in3":
variant_files = [f"{waveshare.variant}.nim", "IT8951.c", "IT8951.h", "IT8951.nim"]
else:
variant_files = [f"{waveshare.variant}.nim", f"{waveshare.variant}.c", f"{waveshare.variant}.h"]

Expand Down
15 changes: 10 additions & 5 deletions backend/app/tasks/deploy_frame.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ async def deploy_frame_task(ctx: dict[str, Any], id: int):
settings = get_settings_dict(db)

async def install_if_necessary(pkg: str, raise_on_error=True) -> int:
search_strings = ["run apt-get update", "404 Not Found", "failed to fetch", "Unable to fetch some archives"]
search_strings = ["run apt-get update", "404 Not Found", "Failed to fetch", "failed to fetch", "Unable to fetch some archives"]
output: list[str] = []
response = await self.exec_command(
f"dpkg -l | grep -q \"^ii {pkg} \" || sudo apt-get install -y {pkg}",
Expand Down Expand Up @@ -346,10 +346,14 @@ async def install_if_necessary(pkg: str, raise_on_error=True) -> int:
must_reboot = False

if drivers.get("bootconfig"):
for line in drivers["bootconfig"].lines:
if await self.exec_command(f'grep -q "^{line}" ' + boot_config, raise_on_error=False) != 0:
await self.exec_command(command=f'echo "{line}" | sudo tee -a ' + boot_config, log_output=False)
must_reboot = True
for line in (drivers["bootconfig"].lines or []):
if line.startswith("#"):
to_remove = line[1:]
await self.exec_command(f'grep -q "^{to_remove}" {boot_config} && sudo sed -i "/^{to_remove}/d" {boot_config}', raise_on_error=False)
else:
if (await self.exec_command(f'grep -q "^{line}" ' + boot_config, raise_on_error=False)) != 0:
await self.exec_command(command=f'echo "{line}" | sudo tee -a ' + boot_config, log_output=False)
must_reboot = True

if frame.last_successful_deploy_at is None:
# Reboot after the first deploy to make sure any modifications to config.txt are persisted to disk
Expand All @@ -366,6 +370,7 @@ async def install_if_necessary(pkg: str, raise_on_error=True) -> int:
if must_reboot:
await update_frame(db, redis, frame)
await self.log("stdinfo", "Deployed! Rebooting device after boot config changes")
await self.exec_command("sudo systemctl enable frameos.service")
await self.exec_command("sudo reboot")
else:
await self.exec_command("sudo systemctl daemon-reload")
Expand Down
27 changes: 20 additions & 7 deletions backend/app/utils/remote_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -438,16 +438,29 @@ async def _run_command_ssh(
stderr_lines: list[str] = []

async def _read_stream(stream, dest: list[str], log_type: str) -> None:
buf = ""
while True:
line = await stream.readline()
if not line:
chunk = await stream.read(32768)
if not chunk:
break
if isinstance(line, (bytes, bytearray)):
line = line.decode(errors="ignore")
line = line.rstrip("\n")
dest.append(line)
if isinstance(chunk, (bytes, bytearray)):
chunk = chunk.decode(errors="ignore")
buf += chunk

# Emit complete lines; keep last partial in buf
*lines, buf = buf.split("\n")
for ln in lines:
ln = ln.rstrip("\r")
dest.append(ln) # <<< store it
if log_output:
await log(db, redis, frame.id, log_type, ln)

# Flush any leftover partial line at EOF
if buf:
ln = buf.rstrip("\r")
dest.append(ln) # <<< store the final partial
if log_output:
await log(db, redis, frame.id, log_type, line)
await log(db, redis, frame.id, log_type, ln)

stdout_task = asyncio.create_task(_read_stream(proc.stdout, stdout_lines, "stdout"))
stderr_task = asyncio.create_task(_read_stream(proc.stderr, stderr_lines, "stderr"))
Expand Down
1 change: 1 addition & 0 deletions backend/list_devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"BlackWhiteYellow": "Black/White/Yellow",
"BlackWhiteYellowRed": "Black/White/Yellow/Red",
"FourGray": "4 Grayscale",
"SixteenGray": "16 Grayscale",
"SevenColor": "7 Color",
"SpectraSixColor": "Spectra 6 Color",
}.get(v.color_option, v.color_option)
Expand Down
28 changes: 28 additions & 0 deletions backend/migrations/versions/2656e03eee76_device_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""device config

Revision ID: 2656e03eee76
Revises: 07cada389a1c
Create Date: 2025-08-15 22:45:16.959190

"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import sqlite

# revision identifiers, used by Alembic.
revision = '2656e03eee76'
down_revision = '07cada389a1c'
branch_labels = None
depends_on = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('frame', sa.Column('device_config', sqlite.JSON(), nullable=True))
# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('frame', 'device_config')
# ### end Alembic commands ###
1 change: 1 addition & 0 deletions e2e/frame.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"width": 320,
"height": 480,
"device": "web_only",
"deviceConfig": {},
"color": null,
"backgroundColor": "blue",
"interval": 1.0,
Expand Down
1 change: 1 addition & 0 deletions frameos/frame.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"width": 800,
"height": 480,
"device": "web_only",
"deviceConfig": {},
"color": null,
"backgroundColor": "blue",
"interval": 1.0,
Expand Down
4 changes: 2 additions & 2 deletions frameos/src/drivers/waveshare/driver.nim
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import ePaper/DEV_Config as waveshareConfig
import ePaper/EPD_2in13_V3 as waveshareDisplay
from ./types import ColorOption
import drivers/waveshare/types

# This file is autogenerated for your frame on deploy
let width* = waveshareDisplay.EPD_2in13_V3_WIDTH
Expand All @@ -14,7 +14,7 @@ proc init*() =
let resp = waveshareConfig.DEV_Module_Init()
if resp != 0: raise newException(Exception, "Failed to initialize waveshare display")

proc start*() =
proc start*(self: Driver) =
# This file is autogenerated for your frame on deploy
waveshareDisplay.EPD_2in13_V3_Init()

Expand Down
Loading