diff --git a/config/target.exs b/config/target.exs index a7a113f..2503fdb 100644 --- a/config/target.exs +++ b/config/target.exs @@ -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 diff --git a/lib/name_badge/application.ex b/lib/name_badge/application.ex index dcdddca..8dbcccc 100644 --- a/lib/name_badge/application.ex +++ b/lib/name_badge/application.ex @@ -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 @@ -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, diff --git a/lib/name_badge/calibration.ex b/lib/name_badge/calibration.ex new file mode 100644 index 0000000..d06c30e --- /dev/null +++ b/lib/name_badge/calibration.ex @@ -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 diff --git a/lib/name_badge/display.ex b/lib/name_badge/display.ex index 75f8c35..f555441 100644 --- a/lib/name_badge/display.ex +++ b/lib/name_badge/display.ex @@ -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) @@ -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 @@ -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 <>, into: <<>> do - <> - 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 diff --git a/lib/name_badge/mocks/display_mock.ex b/lib/name_badge/mocks/display_mock.ex index 2d5ece6..4df2ff2 100644 --- a/lib/name_badge/mocks/display_mock.ex +++ b/lib/name_badge/mocks/display_mock.ex @@ -23,11 +23,11 @@ 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) @@ -35,13 +35,13 @@ defmodule NameBadge.DisplayMock do 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) @@ -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 @@ -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 <>, into: <<>>, do: <> + end end diff --git a/lib/name_badge/screen.ex b/lib/name_badge/screen.ex index c8f1a7d..3a28b7d 100644 --- a/lib/name_badge/screen.ex +++ b/lib/name_badge/screen.ex @@ -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 diff --git a/lib/name_badge/screen/settings.ex b/lib/name_badge/screen/settings.ex index c547d14..2b7d0b5 100644 --- a/lib/name_badge/screen/settings.ex +++ b/lib/name_badge/screen/settings.ex @@ -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 diff --git a/lib/name_badge/screen/settings/calibrate.ex b/lib/name_badge/screen/settings/calibrate.ex new file mode 100644 index 0000000..75f05f6 --- /dev/null +++ b/lib/name_badge/screen/settings/calibrate.ex @@ -0,0 +1,168 @@ +defmodule NameBadge.Screen.Settings.Calibrate do + @moduledoc """ + Two-step grayscale calibration wizard for the 4-level UC8276 panel. + + The panel shows exactly 4 levels: black and white are fixed anchors, the two + middle grays are all you can move. With only two buttons (short A/B adjust, + long A "next", long B "exit"), a single gray can be tuned per screen — so the + two mids split across two steps: + + Step 1 — Dark gray (tune the darker mid) + Step 2 — Light gray (tune the lighter mid) + + Both steps show the same full-screen photo, dithered to the 4 levels at + runtime, so you judge each gray against a real image. + + Each step fills the whole 400x300 screen: a value label on top, a plain + marker bar on the left (dark at top → light at bottom, a single notch at the + current count — no gradient, so it reads as pure position, and scaled to the + usable count range rather than the full 0..54), the maximized test image, and + all four button hints on the bottom. + + Short A darkens / short B lightens the current step's gray, applied live via + `NameBadge.Calibration` (~1s redraw). Long A advances; on step 2 it saves to + `/data` and exits. Long B exits without saving (reverts on reboot). + """ + + use NameBadge.Screen + + alias NameBadge.Calibration + + # Height of the bar/image row (also the photo's square px size) and bar width. + @row_h 236 + @bar_w 20 + # The response curve is steep — only the first ~15 counts are usable (higher + # just saturates to white), so the marker bar scales to this, not to 54. + @usable_max 16 + # Marker notch height, in pt. + @marker_h 4 + + # Full-tone test photo (Kodak "kodim17" from the Kodak Lossless True Color + # Image Suite, https://r0k.us/graphics/kodak/). + @test_photo "kodim17.png" + + @impl NameBadge.Screen + def mount(_args, screen) do + {dark, light} = Calibration.load() + Calibration.set(dark, light) + dither_photo() + + screen = + screen + |> assign(step: :dark, dark: dark, light: light) + |> assign(button_hints: %{a: "Darker", b: "Lighter"}) + # Content is composed at exactly the 4 levels {0, 85, 170, 255} — flat + # fills, and the photo error-diffused to those values (below) — so eink's + # own dithering is off; each pixel maps 1:1 onto the calibrated levels. + |> assign(render_opts: [mode: :grayscale, dither: false]) + + {:ok, screen} + end + + # Take the bundled full-tone photo and run it through the same pipeline the + # grayscale screens use (grayscale → 2-bit error diffusion), so the preview + # shows how a real image actually renders on the 4-level panel. Written once + # per screen entry to a tmp file the Typst compose step embeds. + defp dither_photo do + Path.join(priv_images(), @test_photo) + |> File.read!() + |> Dither.decode!() + |> Dither.resize!(@row_h, @row_h) + |> Dither.grayscale!() + |> Dither.dither!(algorithm: :stucki, bit_depth: 2) + |> Dither.encode!() + |> then(&File.write!(photo_path(), &1)) + end + + # ── Buttons ──────────────────────────────────────────────────────────────── + # Short A/B nudge the current gray; long A advances (save+exit on step 2). + # Long B → back is handled globally by NameBadge.Screen. + + @impl NameBadge.Screen + def handle_button(:button_1, :single_press, screen), do: {:noreply, nudge(screen, -1)} + def handle_button(:button_2, :single_press, screen), do: {:noreply, nudge(screen, +1)} + + def handle_button(:button_1, :long_press, %{assigns: %{step: :dark}} = screen) do + {:noreply, assign(screen, step: :light)} + end + + def handle_button(:button_1, :long_press, %{assigns: %{step: :light}} = screen) do + Calibration.save(screen.assigns.dark, screen.assigns.light) + {:noreply, navigate(screen, :back)} + end + + def handle_button(_button, _press, screen), do: {:noreply, screen} + + # Adjust the current step's gray by `delta` counts, re-apply live, clamp. + defp nudge(%{assigns: %{step: :dark, dark: d, light: l}} = screen, delta) do + {d, l} = Calibration.set(d + delta, l) + assign(screen, dark: d, light: l) + end + + defp nudge(%{assigns: %{step: :light, dark: d, light: l}} = screen, delta) do + {d, l} = Calibration.set(d, l + delta) + assign(screen, dark: d, light: l) + end + + # ── Render ─────────────────────────────────────────────────────────────── + # Full-bleed 400x300 composition (returns a PNG, bypassing the bordered + # settings layout) so the test image gets the whole screen. Layout: + # label ──────────────────── + # [bar] │ big test image + # hold A · A · B · hold B + + # The dithered photo, filling the large right-hand cell (both steps). + @photo ~s|align(center + horizon, image("photo.png", height: 100%, fit: "contain"))| + + @impl NameBadge.Screen + def render(%{step: :dark} = assigns) do + {default, _} = Calibration.defaults() + compose("Dark gray", assigns.dark, default, "next") + end + + def render(%{step: :light} = assigns) do + {_, default} = Calibration.defaults() + compose("Light gray", assigns.light, default, "save") + end + + # Build the full-page grayscale composition and render it to a PNG. Rendered + # from the tmp root so it can embed the runtime-dithered photo. + defp compose(label, value, default, long_a) do + # Plain track (dark at top, light at bottom), with a single marker notch at + # the current count — no gradient, so the bar reads as pure position. + frac = min(value, @usable_max) / @usable_max + marker_dy = Float.round(frac * (@row_h - @marker_h), 1) + + template = """ + #set page(width: 400pt, height: 300pt, margin: (x: 14pt, top: 10pt, bottom: 8pt)) + #set text(font: "Poppins", size: 14pt) + + #stack(dir: ttb, spacing: 8pt, + text(size: 16pt)[#{label}: #text(weight: "bold")[#{value}] #h(8pt) #text(size: 12pt, fill: rgb(85, 85, 85))[(default=#{default})]], + grid(columns: (#{@bar_w}pt, 1fr), column-gutter: 12pt, rows: (#{@row_h}pt,), + box(width: #{@bar_w}pt, height: #{@row_h}pt)[ + #rect(width: 100%, height: 100%, stroke: 1pt + black) + #place(top + left, dy: #{marker_dy}pt, rect(width: 100%, height: #{@marker_h}pt, fill: black)) + ], + #{@photo} + ), + align(center, text(size: 11pt, fill: rgb(85, 85, 85))[hold A: #{long_a} · A darker · B lighter · hold B: exit]) + ) + """ + + Typst.render_to_png!(template, [], root_dir: tmp_root(), extra_fonts: [fonts_dir()]) + |> List.first() + end + + # ── Paths ──────────────────────────────────────────────────────────────── + + defp priv_images, do: Application.app_dir(:name_badge, "priv/typst/images") + defp fonts_dir, do: Application.app_dir(:name_badge, "priv/typst/fonts") + defp photo_path, do: Path.join(tmp_root(), "photo.png") + + defp tmp_root do + dir = Path.join(System.tmp_dir!(), "name_badge_calibrate") + File.mkdir_p!(dir) + dir + end +end diff --git a/mix.exs b/mix.exs index ac773e6..e2323d8 100644 --- a/mix.exs +++ b/mix.exs @@ -46,7 +46,9 @@ defmodule NameBadge.MixProject do {:toolshed, "~> 0.4.0"}, {:slipstream, "~> 1.2"}, {:req, "~> 0.5"}, - {:dither, "~> 0.1.1"}, + # eink's grayscale drawing pipeline needs dither 0.2; override the + # transitive 0.1 constraint from the older eink API. + {:dither, "~> 0.2.4", override: true}, {:typst, "~> 0.3"}, {:qr_code, "~> 3.2.0"}, {:tzdata, "~> 1.1"}, @@ -61,7 +63,13 @@ defmodule NameBadge.MixProject do {:nerves_pack, "~> 0.7.1", targets: @all_targets}, {:circuits_spi, "~> 2.0", targets: @all_targets}, {:circuits_gpio, "~> 2.1.3", targets: @all_targets}, - {:eink, github: "protolux-electronics/eink", targets: @all_targets}, + # Display calibration needs the runtime waveform-override API + # (EInk.set_waveform/2) + the 4-gray drawing pipeline. Both live on + # protolux-electronics/eink#12, whose head branch is this fork branch. + # BLOCKER: swap to protolux-electronics/eink (hex or tag) once #12 merges + # and a release is cut — this PR can't merge before that. + {:eink, + github: "markomitranic/eink", branch: "waveform-override-api", targets: @all_targets}, {:vintage_net_wizard, github: "nerves-networking/vintage_net_wizard", targets: @all_targets}, diff --git a/mix.lock b/mix.lock index 0e7ada3..48ec01e 100644 --- a/mix.lock +++ b/mix.lock @@ -8,8 +8,8 @@ "circuits_spi": {:hex, :circuits_spi, "2.0.4", "b75f64c0401e3c64319dcfc76a9bc17466e4469adc5daf34165f4c4b11cf12ee", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "ccf034065091f26c624dee777ea3f48ce64696af812622e1d060b2cffdbf90e4"}, "circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, - "dither": {:hex, :dither, "0.1.1", "1d3bb11c043427e0c5f5828ef324e9aac6d295ad4eb9b6e1cd6d8521947142d0", [:mix], [{:rustler, "~> 0.36.2", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "3f8f21275e8e8bf2b5f65f272b647d9dd630d19b0d03006c8c948d8c993d63f0"}, - "eink": {:git, "https://github.com/protolux-electronics/eink.git", "2304b0ce685a1a12e1a2d3a4ea2e257843d6370e", []}, + "dither": {:hex, :dither, "0.2.4", "ab4e8ab112c7e0dbfabb859b5b786fc6a8f46a40f8d9d8d8154c663ee7307b0c", [:mix], [{:rustler, "~> 0.36.2", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "a34f2cc402f431c34cc21d7c89e54fc6eb1b428e26b5980f15bc31df3265bf8f"}, + "eink": {:git, "https://github.com/markomitranic/eink.git", "57dad3565dd5a3f335e2a53ae755e11a05455e58", [branch: "waveform-override-api"]}, "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "ex_maybe": {:hex, :ex_maybe, "1.1.1", "95c0188191b43bd278e876ae4f0a688922e3ca016a9efd97ee7a0b741a61b899", [:mix], [], "hexpm", "1af8c78c915c7f119a513b300a1702fc5cc9fed42d54fd85995265e4c4b763d2"}, "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, @@ -24,7 +24,7 @@ "icalendar": {:hex, :icalendar, "1.1.3", "240b2ac4e350901951c73883a22ad4c34ad318a716eb2e06967006870d9d24a1", [:mix], [{:timex, "~> 3.4", [hex: :timex, repo: "hexpm", optional: false]}], "hexpm", "0d128cfee4d2b0c498a72de717ecf99fd1db726edd708ccf7c5d6f916e47ee8a"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "interactive_cmd": {:hex, :interactive_cmd, "0.1.3", "4641cf8e2bc6f235b5093813d57b7e42e12c3844942a2b5912eb8a996bda6e28", [:mix], [], "hexpm", "11e182eb7064ccf3610d4b12cdb99467e473548e9e31961582635e4db24c2aa7"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, "lazy_html": {:hex, :lazy_html, "0.1.10", "ffe42a0b4e70859cf21a33e12a251e0c76c1dff76391609bd56702a0ef5bc429", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "50f67e5faa09d45a99c1ddf3fac004f051997877dc8974c5797bb5ccd8e27058"}, "matrix_reloaded": {:hex, :matrix_reloaded, "2.3.0", "eea41bc6713021f8f51dde0c2d6b72e695a99098753baebf0760e10aed8fa777", [:mix], [{:ex_maybe, "~> 1.0", [hex: :ex_maybe, repo: "hexpm", optional: false]}, {:result, "~> 1.7", [hex: :result, repo: "hexpm", optional: false]}], "hexpm", "4013c0cebe5dfffc8f2316675b642fb2f5a1dfc4bdc40d2c0dfa0563358fa496"}, "mdns_lite": {:hex, :mdns_lite, "0.9.1", "fb305081d01aa62d38d86a4e467e9b7eb217aa68d62c22b111105170ca7f198f", [:mix], [{:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:vintage_net, "~> 0.7", [hex: :vintage_net, repo: "hexpm", optional: true]}], "hexpm", "4f5ae0c6cc69fcc3adadf68bfd54abfc5a284defa703d6faefe40ade2145a601"}, diff --git a/priv/typst/images/kodim17.png b/priv/typst/images/kodim17.png new file mode 100644 index 0000000..c183999 Binary files /dev/null and b/priv/typst/images/kodim17.png differ