Skip to content
decentespressoPublic

About

No description, website, or topics provided.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

DYE2

DYE2 (Describe Your Espresso) is a plugin for Decaid — the tablet software for Decent Espresso machines. It manages coffee beans, roast batches, grinders and equipment, and adds a shot dashboard, auto-favourites and recipe editing on top of the stock DYE workflow.

This repo is the source of truth for DYE2. Decaid pulls a pinned release of it as a build asset; nothing here is edited inside Decaid's tree.

I want to… Go to
Use DYE2 User manual · Releases
Work on the code (human or AI) Repository map — routes any task to the one note that covers it
Build a Decaid plugin For plugin developers below
Read the plugin contract Decaid's doc/Plugins.md

For users

Most people never install this by hand. Decaid ships a pinned DYE2 release inside the app and copies it out on first start.

Install a specific version only if you want to run ahead of or behind what Decaid pins:

  1. Download dye2.reaplugin-vX.Y.Z.zip from Releases.
  2. Decaid → Settings → Plugins → install from ZIP.

Or drop the unzipped dye2.reaplugin/ folder into Decaid's plugins/ directory and enable it.


For plugin developers

This repo is a worked example of a Decaid plugin that lives in its own repository and gets bundled into the app from a pinned GitHub release. If you're writing a plugin with a build step (TypeScript, bundler, npm tests), copy this shape.

Read Decaid's doc/Plugins.md first for the general contract — manifest fields, the host API, the HTTP request handler, lifecycle and sandbox rules. This README expands on it from the perspective of a plugin that lives in its own repo: layout, build, release, and the Decaid-side wiring.

The one concept that trips everyone up: two runtimes

A Decaid plugin is not a web app. It is two programs in one repo that never share a scope.

Plugin runtime Browser runtime
Where On-device, inside flutter_js The tablet's WebView
Code dye2-plugin/src/plugin.ts, pages/, and the module-level parts of utils/ The template-literal strings those files emit, plus everything in dev/
Has host.log, host.emit, host.storage, host.decentProxy, fetch Full DOM, fetch, CustomEvent
No DOM, window, document, timers beyond setTimeout/clearTimeout host.*
Job Answer HTTP-handler calls — a server-side HTML renderer Render and interact once the HTML lands

Note the split runs through files, not just between them: src/utils/dev-api.ts is plugin-runtime TypeScript whose main export is a long string of browser JavaScript.

The plugin runtime emits browser code as strings; it never executes it. Import a browser API into plugin-runtime code and it fails on-device while working fine in your editor.

Repo layout

dye2/
├── dye2-plugin/              # The plugin source (TypeScript, built)
│   ├── src/
│   │   ├── plugin.ts         # Entry point — implements PluginInstance
│   │   ├── host.d.ts         # flutter_js host API types
│   │   ├── pages/            # Page renderers, roughly one per declared `api` id
│   │   ├── components/       # Web Component experiment — not wired up
│   │   ├── api/client.ts     # Earlier REST client — not wired up
│   │   ├── styles/           # Tailwind input + generated CSS
│   │   ├── utils/            # html`` template, escaping, chart, date picker
│   │   └── vendor/           # Vendored browser libs (echarts)
│   ├── test/                 # Node test runner (.test.mjs)
│   ├── manifest.src.json     # Plugin metadata — SOURCE of manifest.json
│   ├── vite.config.ts        # IIFE build → ../dye2.reaplugin/plugin.js
│   └── dev-server.mjs        # Local page server, proxies /api/v1/* to a machine
│
├── dye2.reaplugin/           # BUILD OUTPUT — committed, never hand-edited
│   ├── manifest.json
│   └── plugin.js
│
├── dev/                      # Legacy plain JS/HTML for Decaid's native DYE pages
├── docs/
│   └── KV_CONTRACT.md        # Schemas for the KV keys DYE2 owns
├── rea_restapi.yml           # Decaid REST OpenAPI spec
└── websocket_v1.yml          # Decaid WebSocket AsyncAPI spec

A shipped plugin is exactly two files — manifest.json and plugin.js in a folder named after the plugin id. Everything else in this repo exists to produce those two files.

dev/ predates the TypeScript rewrite; its pages have since been ported into the plugin. No build step; edit and reload. Nothing in Decaid's own tree references it, so treat it as reference material rather than a live surface.

Build and run

cd dye2-plugin
npm install

npm run build    # vite → ../dye2.reaplugin/{plugin.js,manifest.json}; runs tailwind itself
npm run serve    # dev server on :4444 + watch build; proxies /api/v1/* to a real machine
npm run dev      # the watch build alone
npm test         # 24 suites under test/, run in series (see package.json)

test names bc-map.test.mjs and shot-paging.test.mjs explicitly rather than globbing, so a new *.test.mjs does not run until you add it to the script.

Run dev and serve in two terminals — the dev server reloads when plugin.js changes on disk.

PORT=4000 BRIDGE_URL=http://192.168.1.5:8080 npm run serve

Point BRIDGE_URL at a tablet or a desktop Decaid instance and the pages talk to real beans, shots and machine state without deploying anything.

The manifest

manifest.src.json is the source; vite.config.ts copies it into the build output as manifest.json. Never create a second file literally named manifest.json outside the output folder — Decaid finds a plugin root by looking for directories that contain one and refuses to install when an archive has more than one candidate.

DYE2's shape:

{
  "id": "dye2.reaplugin",
  "author": "Streamline",
  "name": "Streamline/DYE2",
  "description": "Beans, grinders, and shot workflow for Decent Espresso.",
  "version": "0.1.19",
  "apiVersion": 1,
  "permissions": ["log", "api", "emit", "pluginStorage", "events.shots", "events.workflow"],
  "settings": {},
  "api": [
    { "id": "dashboard", "type": "http", "data": {} },
    { "id": "bean-picker", "type": "http", "data": {} },
    { "id": "changed", "type": "websocket", "data": {} }
  ]
}

DYE2 declares 20 api entries, 19 http and one websocket; three are shown. author and description are required — Decaid reads them into non-nullable fields, so omitting either fails the install with Invalid manifest.json.

  • id becomes a directory name. Single safe path component — no /, \, ., .., no Windows-reserved names.
  • permissions are enforced. host.log, host.emit and host.storage throw PluginPermissionError when undeclared; fetch and host.decentProxy return a rejected promise instead. Declare the minimum: every added permission blocks automatic updates until a user approves it (see Permission escalation). The events.* permissions decide which events reach onEvent: DYE2 takes events.shots for shotStored/shotUpdated (it recomputes recent auto-favourites) and events.workflow for workflowUpdated, which carries the whole workflow on every change and is the only push a plugin gets for it — GET /workflow has no revision and no socket.
  • api entries each declare one endpoint. type: "http" is served at /api/v1/plugins/<id>/<api-id>; type: "websocket" at /ws/v1/plugins/<id>/<api-id>, which streams whatever the plugin passes to host.emit('<api-id>', payload) — DYE2's changed endpoint is that, and a skin watching it re-reads instead of polling. Decaid calls __httpRequestHandler on your instance, aliasing handleHttpRequest onto it if that is what you defined — this repo defines __httpRequestHandler directly. DYE2's dashboard id means GET /api/v1/plugins/dye2.reaplugin/dashboard returns a full HTML page.
  • settings are rendered by skins straight from this schema. Give every setting a label and a description — without a label the user sees the raw storage key.

Key patterns

  • Browser code as string exports. A runtime with no DOM ships browser JavaScript by returning it as text. Shared browser code lives in src/utils/ — dev-api.ts, shot-paging.ts, chart.ts, shared-components.ts, icons.ts — each exporting a template literal that the shell inlines as a <script> tag.
  • Page assembly. Every live page calls devPageShell(title, content, styles, scripts, opts) from src/utils/dev-shell.ts, passing the shared strings plus its own orchestration script. For example, pages/grinders.ts ends with devPageShell("Grinders", content, styles, [devApiScript, pageScript]).
  • HTML safety. The html tagged template in src/utils/html.ts does not escape. Wrap anything user-supplied in escapeHtml() from the same file.
  • API base. Browser code calls /api/v1/* directly. The dev server proxies it; Decaid routes it in production. No base-URL config anywhere.
  • Not every module is a page. src/utils/recent-favs.ts and src/utils/workflow-record.ts run in the plugin runtime as real TypeScript, not as exported strings: plugin.ts imports them, they touch no DOM, they take fetch as an argument, and so node:test imports them directly (test/recent-favs.test.mjs, test/workflow-record.test.mjs). That is where work belongs that has to happen without a page open — answering an event, or writing the store after a shot. Code shaped this way is type-checked and testable; the page strings are neither.

Two directories are historical and wired to nothing: src/components/ (a Web Component experiment, and the only place in the tree using customElements.define or CustomEvent) and src/api/client.ts. pageShell() in pages/layout.ts is the older three-argument shell they were built against; it has no remaining callers. Read them for context, but copy devPageShell and the utils/ strings.

Data: real resources vs. the KV store

Beans and grinders are first-class Decaid resources:

GET /api/v1/beans
GET /api/v1/grinders

Auto-favourites, recipes, (for now) filter baskets, and equipment have no resource of their own, so DYE2 persists them in Decaid's generic per-plugin KV store:

GET /api/v1/store/dye2.reaplugin/autoFavourites
GET /api/v1/store/dye2.reaplugin/recipes
GET /api/v1/store/dye2.reaplugin/baskets
GET /api/v1/store/dye2.reaplugin/equipment

No auth. Returns a JSON array, or null if the key was never written — treat null as []. Full schemas, field-by-field, plus a worked query example: docs/KV_CONTRACT.md.

One more key is a single object rather than an array:

GET /api/v1/store/dye2.reaplugin/selection

the recipe or favourite currently being dialled in (null when none). A skin arms it with POST /api/v1/plugins/dye2.reaplugin/selection {kind, id} — {} clears — right after it applies an item. DYE2 then watches workflowUpdated and writes the dashboard's dose, yield and grind edits into that item itself, so a skin never has to touch the arrays. It is written through host.storage, which lands in this same KV store, which is why it is readable at the URL above.

The KV route is not scoped to the owning plugin. Any skin can already read and write these keys. DYE2 is the sole writer by convention, not by enforcement — don't POST/DELETE keys you don't own. There is no revision or ETag either, and every mutation rewrites a whole array, so a second writer races the entire collection rather than one row.

A consumer does not have to poll: DYE2 host.emits on its changed websocket endpoint after it writes, and Streamline's plugin-surface live channel re-reads on that push.

Baskets are transitional: decaid#727 adds a real /api/v1/equipment resource to migrate onto.

Full API surface: rea_restapi.yml and websocket_v1.yml.

Releasing

The git tag is the version. Never hand-bump ahead of a tag — the workflow sets the version from the tag itself.

git tag v0.2.0
git push origin v0.2.0

.github/workflows/release.yml then:

  1. Rewrites manifest.src.json .version and package.json to the tag minus its v.
  2. Builds (npm ci && npm run build).
  3. Validates the output — both files non-empty, id matches, apiVersion and version present, plugin.js contains createPlugin.
  4. Zips with dye2.reaplugin/ as the top-level entry — the bundling fetch script unpacks into assets/plugins/ and then reads assets/plugins/dye2.reaplugin/manifest.json, so the folder must be there. (Decaid's own installer is more relaxed: it accepts a flat zip or a single wrapper directory, and takes the install directory name from manifest.id.)
  5. Publishes dye2.reaplugin-vX.Y.Z.zip to Releases.

Pushes to main carry no tag, so they skip 1–5 and instead publish the built package to a dist branch — see the rules below.

Rules the workflow can't enforce for you:

  • Commit the rebuilt dye2.reaplugin/. A push to main whose committed build differs from a clean build raises a CI warning. Note main itself is not installable from a branch — its root has no manifest.json — so branch installs go through dist or a release.
  • The dist branch root is the contents of dye2.reaplugin/, not the folder. Decaid descends exactly one level under GitHub's archive wrapper to find the package root, so a dye2.reaplugin/ directory inside the branch would be one level too deep and fail with "Plugin package has no manifest.json". A branch root holding two manifest-bearing directories also fails, deliberately.
  • Release and dist share one install slot. Both carry the same manifest.id, so switching channel overwrites the recorded source. Install one or the other, not both.
  • Keep the branch head at or above the latest tag. Decaid refuses any install that would downgrade an existing one unless downgrades are explicitly allowed. After tagging, bring main's manifest version up to the released version and commit the rebuild — this is the one bump you make by hand, and it follows the tag rather than leading it.
  • That rule governs dist too, and it bites in the window between a release and that bump. A tag build takes the tag's version while main's committed manifest still holds the previous one, so dist publishes the older version; installing it over the release is then refused as a downgrade (HTTP 409, "Plugin dye2.reaplugin X.Y.Z is installed; remove it before installing …"). Bump main before relying on dist, or install the release.
  • dist is a rolling target. force_orphan rewrites the branch on every push to main, so the recorded source.commit moves and "install from dist" can never be pinned. Pin with a release when that matters.

Getting a plugin bundled into Decaid

Bundling means Decaid ships your plugin inside the app and copies it out at startup. It is for plugins Decent wants on every install — otherwise just publish releases and let users install from GitHub.

Two models exist. This repo is the second.

In-tree External repo (this one)
Lives in assets/plugins/<id>.reaplugin/, committed to Decaid Its own repo; release ZIP fetched at build time
Build step None — hand-written plugin.js Anything you like
Decaid's copy Tracked in git Gitignored — a build artifact
Examples time-to-ready, visualizer, settings, decent-profile dye2, shot-upload

What you provide

  • A public GitHub repo that publishes <id>.reaplugin-vX.Y.Z.zip on every tag.
  • The zip contains one top-level folder named exactly <id>.reaplugin, holding manifest.json and plugin.js.
  • manifest.json .version equals the tag without its v.
  • plugin.js contains createPlugin.

What the Decaid PR changes

Copy scripts/fetch_dye2_plugin.sh and change the names. It pins a version and a SHA-256, then hard-fails on any contract violation:

pinned_version="v0.1.4"
pinned_sha256="fd8e43af…"
pinned_api_version="1"

It downloads the release asset, verifies the checksum, unpacks into assets/plugins/, and asserts the manifest id, the manifest version against the tag, apiVersion, the required permissions, and the createPlugin entry point. A drifted release breaks the build instead of shipping quietly.

Then the wiring, all in Decaid:

File Change
scripts/fetch_<name>_plugin.sh New — copy the dye2 one
.gitignore Ignore assets/plugins/<id>.reaplugin/ — build output
pubspec.yaml (assets:) Add - assets/plugins/<id>.reaplugin/
lib/src/plugins/plugin_loader_service.dart Add the path to _getBundledPluginPaths()
lib/src/plugins/plugin_source_service.dart Add '<id>.reaplugin': 'owner/repo' to bundledPluginRepos
test/plugins/bundled_plugin_permissions_test.dart Add your permission floor to requiredPermissions
.github/workflows/{pr-checks,develop-builds,release}.yml Call your fetch script beside the existing ones — ~28 call sites across the three. Miss release.yml and your plugin is absent from every shipped build

bundledPluginRepos is what lets an installed copy keep receiving your releases after install — without it your plugin freezes at whatever version Decaid pinned.

The permissions test asserts containsAll, so it's a floor, not an exact match: extra permissions pass, a dropped one fails.

Bundled-copy semantics

The bundled copy is the app-owned floor. In a release build, Decaid compares versions on every startup:

  • bundled newer than installed → bundled wins, replaces it
  • bundled equal or older → installed copy is left alone

So a user who installed a newer release from GitHub keeps it, and a hand-edit survives as long as the bundled version hasn't moved past it.

In debug and profile builds there is no comparison — the bundled copy overwrites the installed one unconditionally at every start. If you develop against Decaid running from source, expect your installed build to be replaced on each launch; install through the Plugins screen after startup, or test against a release build.

Permission escalation

An automatic update installs only when the candidate manifest requests the same permissions or fewer. Asking for anything new parks it as a pendingUpdate — surfaced in the Plugins screen and in GET /api/v1/plugins — until approved:

curl -X POST http://tablet:8080/api/v1/plugins/dye2.reaplugin/update/approve

Plan permissions ahead. Adding one later means every user has to click through.

Bumping the pin

When you cut a release, open a normal PR against Decaid updating pinned_version and pinned_sha256 in your fetch script:

shasum -a 256 dye2.reaplugin-v0.2.0.zip

If the release added permissions, update requiredPermissions in the test in the same PR.


Copying this repo as a template

  1. Rename dye2-plugin/ → <your-plugin>/ and set id, name, author, description in manifest.src.json.
  2. Point vite.config.ts output at ../<your-id>.reaplugin/.
  3. Declare your api page ids in the manifest and add one file per id under src/pages/.
  4. Trim permissions to what you actually call.
  5. Keep .github/workflows/release.yml — change only the folder and artifact names.
  6. Tag v0.1.0 and confirm the release zip has <your-id>.reaplugin/ at its top level.
  7. Ship it standalone first. Propose bundling only once it's stable.

About

No description, website, or topics provided.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages