Skip to content
Open
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
15 changes: 15 additions & 0 deletions config/target.exs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,21 @@ config :nerves, :erlinit, update_clock: true
# set tzdata dir for Nerves device
config :tzdata, :data_dir, "/data/tzdata"

# E-ink display (new drawing-pipeline API). Started as a named GenServer from
# the supervision tree; drivers read this app env in EInk.init/1.
config :eink,
driver: EInk.Driver.UC8276,
width: 400,
height: 300,
driver_config: [
dc_pin: "EPD_DC",
reset_pin: "EPD_RESET",
busy_pin: "EPD_BUSY",
spi_device: "spidev0.0",
spi_opts: [speed_hz: 1_000_000],
debug: false
]

# Configure the device for SSH IEx prompt access and firmware updates
#
# * See https://hexdocs.pm/nerves_ssh/readme.html for general SSH configuration
Expand Down
11 changes: 10 additions & 1 deletion lib/name_badge/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ defmodule NameBadge.Application do
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: NameBadge.Supervisor]
Supervisor.start_link(children, opts)
result = Supervisor.start_link(children, opts)

# EInk forgets waveform overrides on restart, so re-apply any saved
# grayscale calibration now that EInk is up (no-op on host / default value).
NameBadge.Calibration.apply_saved!()

result
end

# List all child processes to be supervised
Expand All @@ -44,6 +50,9 @@ defmodule NameBadge.Application do
button_spec(:button_1),
button_spec(:button_2),
NameBadge.Battery,
# EInk singleton (configured via `config :eink`) must start before Display,
# which paints the boot frame through it.
EInk,
NameBadge.Display,
NameBadge.TimezoneService,
NameBadge.Weather,
Expand Down
135 changes: 135 additions & 0 deletions lib/name_badge/calibration.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
defmodule NameBadge.Calibration do
@moduledoc """
Persisted 4-level grayscale calibration for the UC8276 panel.

The panel renders exactly 4 levels: pure black and pure white are fixed
anchors, and the two middle grays are the only thing tunable. This module
stores where those two mids sit (as whiten-frame counts on the 150Hz scale)
and pushes them to the chip via the eink runtime waveform-override API
(`EInk.set_waveform/2`).

The value survives reboots in `/data/display_calibration.json`; the EInk
GenServer forgets the override on restart, so `apply_saved!/0` re-applies it
at boot (called from `NameBadge.Application`).

`EInk.set_waveform/2` only exists on the eink `waveform-override-api` branch
(protolux-electronics/eink#12). Every hardware call is guarded, so this
compiles on the host simulator and on an older eink pin — it just no-ops
until that API is available.
"""

require Logger

# Fixed anchors — the calibrated baseline is `[0, 5, 10, 54]` on the 0..63
# whiten-frame scale (black, dark mid, light mid, white).
@black 0
@white 54
@default_dark 5
@default_light 10

@doc "Baseline mids the calibration ships with: `{dark, light}`."
def defaults, do: {@default_dark, @default_light}

@doc "Fixed black/white anchor counts the mids sit between."
def anchors, do: {@black, @white}

@doc """
Load the persisted `{dark, light}` counts, falling back to `defaults/0`.

## Example

Calibration.load() # {5, 10}
"""
def load do
with {:ok, json} <- File.read(store_file()),
%{"dark" => d, "light" => l} when is_integer(d) and is_integer(l) <- :json.decode(json) do
clamp(d, l)
else
_ -> defaults()
end
rescue
_ -> defaults()
end

@doc """
Persist `{dark, light}` (clamped) to disk. Returns the clamped tuple.

## Example

Calibration.save(3, 11) # {3, 11}
"""
def save(dark, light) do
{dark, light} = clamp(dark, light)
File.write(store_file(), :json.encode(%{"dark" => dark, "light" => light}))
{dark, light}
end

@doc """
Push `{dark, light}` to the panel for all later `mode: :grayscale` draws.

Clamps to a valid, monotonic range and returns the applied tuple. Costs one
panel re-init (~1s) on the *next* grayscale draw — the caller must trigger a
redraw for the change to show.

## Example

Calibration.set(4, 12) # {4, 12}, override now live
"""
def set(dark, light) do
{dark, light} = clamp(dark, light)

if eink_ready?() do
try do
lut = build_lut(dark, light)
Kernel.apply(EInk, :set_waveform, [:grayscale, [lut: lut]])
rescue
e -> Logger.warning("Calibration.apply/2 failed: #{inspect(e)}")
end
else
Logger.debug("Calibration.apply/2: EInk.set_waveform unavailable; skipping (host or old eink pin)")
end

{dark, light}
end

@doc """
Re-apply the persisted calibration at boot, only if it differs from the
baseline (baseline == packaged default LUT, so no override needed).
"""
def apply_saved! do
{dark, light} = load()

# Baseline == the packaged default LUT, so an override is only needed when
# the saved value has been moved off it.
if {dark, light} != defaults() do
set(dark, light)
end

:ok
rescue
_ -> :ok
end

@doc "Clamp to `0 ≤ dark ≤ light ≤ white`, each on the 0..63 count scale."
def clamp(dark, light) do
dark = dark |> min(@white) |> max(@black)
light = light |> min(@white) |> max(dark)
{dark, light}
end

# Build the 5 grayscale LUT registers from the two mids. Uses the library
# builder on the new eink branch; guarded so it's only reached on target.
defp build_lut(dark, light) do
Kernel.apply(EInk.Driver.UC8276.Settings, :grayscale_lut, [[@black, dark, light, @white]])
end

defp eink_ready? do
Code.ensure_loaded?(EInk) and function_exported?(EInk, :set_waveform, 2)
end

if Mix.target() == :host do
defp store_file, do: Path.join(System.tmp_dir!(), "display_calibration.json")
else
defp store_file, do: "/data/display_calibration.json"
end
end
71 changes: 23 additions & 48 deletions lib/name_badge/display.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,60 +3,62 @@ defmodule NameBadge.Display do

require Logger

@threshold 127

def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end

@doc """
Render a Typst template to the display.

`opts` are passed straight to `EInk.draw/2` — notably `mode: :full | :fast | :grayscale`.
"""
def render_typst(markup, opts \\ []) do
GenServer.call(__MODULE__, {:render_typst, markup, opts})
end

@doc """
Render a PNG (binary or Dither ref) to the display. `opts` forwarded to `EInk.draw/2`.
"""
def render_png(png, opts \\ []) do
GenServer.call(__MODULE__, {:render_png, png, opts})
end

@impl GenServer
def init(_opts) do
{:ok, eink} =
EInk.new(EInk.Driver.UC8276,
dc_pin: "EPD_DC",
reset_pin: "EPD_RESET",
busy_pin: "EPD_BUSY",
spi_device: "spidev0.0"
)

EInk.clear(eink, :white)
EInk.draw(eink, initial_frame())
# The EInk singleton is started separately (see NameBadge.Application +
# `config :eink`). Here we just paint the boot frame through it.
EInk.clear(:white)
EInk.draw(initial_frame())

# this sleep blocks the init of other processes in the
# supervision tree, creating a short "loading screen"
Process.sleep(3_000)

{:ok, %{eink: eink}}
{:ok, %{}}
end

@impl GenServer
def handle_call({:render_typst, markup, opts}, _from, state) do
eink_data =
eval_template(markup)
|> prepare_png()

EInk.draw(state.eink, eink_data, opts)
eval_template(markup)
|> Dither.decode!()
|> EInk.draw(opts)

{:reply, :ok, state}
end

@impl GenServer
def handle_call({:render_png, png, opts}, _from, state) do
eink_data = prepare_png(png)

EInk.draw(state.eink, eink_data, opts)
to_dither(png)
|> EInk.draw(opts)

{:reply, :ok, state}
end

# EInk.draw treats a raw binary as already-packed pixel data, so PNG bytes must
# be decoded into a %Dither{} first. A Dither ref is already decoded.
defp to_dither(png) when is_binary(png), do: Dither.decode!(png)
defp to_dither(ref) when is_reference(ref), do: ref

defp initial_frame() do
"""
#set page(width: 400pt, height: 300pt)
Expand All @@ -65,9 +67,6 @@ defmodule NameBadge.Display do
|> Typst.render_to_png!([], root_dir: Application.app_dir(:name_badge, "priv/typst"))
|> List.first()
|> Dither.decode!()
|> Dither.grayscale!()
|> Dither.to_raw!()
|> pack_bits()
end

def eval_template(template) do
Expand All @@ -77,30 +76,6 @@ defmodule NameBadge.Display do
|> List.first()
end

defp prepare_png(png) when is_binary(png) do
Dither.decode!(png)
|> prepare_png()
end

defp prepare_png(ref) when is_reference(ref) do
ref
|> Dither.grayscale!()
|> Dither.to_raw!()
|> pack_bits()
end

defp pack_bits(""), do: ""

defp pack_bits(binary) do
for <<b0, b1, b2, b3, b4, b5, b6, b7 <- binary>>, into: <<>> do
<<threshold(b0)::1, threshold(b1)::1, threshold(b2)::1, threshold(b3)::1, threshold(b4)::1,
threshold(b5)::1, threshold(b6)::1, threshold(b7)::1>>
end
end

defp threshold(b) when b >= @threshold, do: 1
defp threshold(b) when b < @threshold, do: 0

defp typst_dir, do: Application.app_dir(:name_badge, "priv/typst")
defp fonts_dir, do: Path.join(typst_dir(), "fonts")
end
34 changes: 25 additions & 9 deletions lib/name_badge/mocks/display_mock.ex
Original file line number Diff line number Diff line change
Expand Up @@ -23,25 +23,25 @@ defmodule NameBadge.DisplayMock do
end

@impl GenServer
def handle_call({:render_typst, markup, _opts}, _from, _state) do
def handle_call({:render_typst, markup, opts}, _from, _state) do
png =
markup
|> NameBadge.Display.eval_template()
|> prepare_png()
|> prepare_png(opts)

send_frame(png)

{:reply, :ok, png}
end

@impl GenServer
def handle_call({:render_png, png_ref_or_binary, _opts}, _from, _state) do
def handle_call({:render_png, png_ref_or_binary, opts}, _from, _state) do
png =
case png_ref_or_binary do
bin when is_binary(bin) -> bin
ref when is_reference(ref) -> Dither.encode!(ref)
end
|> prepare_png()
|> prepare_png(opts)

send_frame(png)

Expand All @@ -62,11 +62,22 @@ defmodule NameBadge.DisplayMock do
|> List.first()
end

defp prepare_png(png) do
Dither.decode!(png)
|> Dither.grayscale!()
|> Dither.to_raw!()
|> threshold()
# Simulate the panel. 1-bit modes hard-threshold to black/white; grayscale
# mode (`render_opts: [mode: :grayscale]`) quantizes to the 4 real levels.
defp prepare_png(png, opts) do
raw =
Dither.decode!(png)
|> Dither.grayscale!()
|> Dither.to_raw!()

mapped =
if Keyword.get(opts, :mode) == :grayscale do
quantize4(raw)
else
threshold(raw)
end

mapped
|> Dither.from_raw!(400, 300)
|> Dither.encode!()
end
Expand All @@ -77,4 +88,9 @@ defmodule NameBadge.DisplayMock do

defp threshold(val) when is_integer(val) and val > 100, do: 255
defp threshold(val) when is_integer(val) and val <= 100, do: 0

# Snap each byte to the nearest of the panel's 4 levels: 0, 85, 170, 255.
defp quantize4(bin) when is_binary(bin) do
for <<b <- bin>>, into: <<>>, do: <<round(b / 85) * 85>>
end
end
4 changes: 4 additions & 0 deletions lib/name_badge/screen.ex
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ defmodule NameBadge.Screen do
end

def handle_continue({:render, render_opts}, screen) do
# Screens can set EInk draw options (e.g. mode: :grayscale) via a
# :render_opts assign, same pattern as :button_hints.
render_opts = Keyword.merge(render_opts, Map.get(screen.assigns, :render_opts, []))

# this is blocking, takes about 1s
screen.module.render(screen.assigns)
|> case do
Expand Down
3 changes: 2 additions & 1 deletion lib/name_badge/screen/settings.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ defmodule NameBadge.Screen.Settings do
{"WiFi Settings", Settings.WiFi},
{"Tutorial", Settings.Tutorial},
{"Sudo Mode", Settings.SudoMode},
{"System Info", Settings.SystemInfo}
{"System Info", Settings.SystemInfo},
{"Calibrate Display", Settings.Calibrate}
]

@impl NameBadge.Screen
Expand Down
Loading