{"id":50911163,"url":"https://github.com/makefu/jura-connect","last_synced_at":"2026-06-16T10:30:31.375Z","repository":{"id":357123985,"uuid":"1235445044","full_name":"makefu/jura-connect","owner":"makefu","description":"wifi connection to your jura coffee machine","archived":false,"fork":false,"pushed_at":"2026-05-11T11:37:11.000Z","size":122,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-11T13:34:55.557Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/makefu.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null}},"created_at":"2026-05-11T10:31:21.000Z","updated_at":"2026-05-11T11:37:15.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/makefu/jura-connect","commit_stats":null,"previous_names":["makefu/jura-connect"],"tags_count":2,"template":false,"template_full_name":null,"purl":"pkg:github/makefu/jura-connect","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/makefu%2Fjura-connect","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/makefu%2Fjura-connect/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/makefu%2Fjura-connect/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/makefu%2Fjura-connect/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/makefu","download_url":"https://codeload.github.com/makefu/jura-connect/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/makefu%2Fjura-connect/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34402648,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-16T02:00:06.860Z","response_time":126,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-06-16T10:30:30.783Z","updated_at":"2026-06-16T10:30:31.360Z","avatar_url":"https://github.com/makefu.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"# jura-connect\n\n[![CI](https://github.com/makefu/jura-connect/actions/workflows/ci.yml/badge.svg)](https://github.com/makefu/jura-connect/actions/workflows/ci.yml)\n\nA dependency-free Python WiFi interface for Jura coffee machines fitted\nwith a **Smart Connect** WiFi dongle. Reverse-engineered from the\nofficial J.O.E. (Jura Operating Experience) Android app and verified\nend-to-end against a **JURA S8 EB** running firmware **TT237W V06.11**\n(\"Kaffeebert\").\n\n## Status\n\n| Capability | Status |\n| --- | --- |\n| UDP/51515 broadcast discovery + parser | ✓ ; falls back to TCP-port-sweep on the TT237W firmware which doesn't reply to UDP |\n| Wire framing (`* … \\r\\n`) and obfuscation cipher | ✓ ; 2 000-input random round-trip + every key value exhaustively tested |\n| storage of authentication codes | ✓ |\n| Read commands: maintenance counters, maintenance %, machine status / alerts, screen lock/unlock | ✓ |\n| Per-machine profiles — 88 bundled XMLs from the J.O.E. APK; alert names + product codes are looked up per `EF_code` so a Cortado on an S8 EB names itself, not `0x2B=2` | ✓ |\n| Brewing / writes / maintenance processes | available but require extra attention |\n\n## Installation\n\nThe package is pure Python ≥ 3.11 with no runtime dependencies. The\nrecommended way is via the flake:\n\n```sh\nnix shell .#jura-connect            # binary + library available in the shell\nnix run .#jura-connect -- discover  # run the CLI directly\n```\n\nOr build/install with the bundled `pyproject.toml`:\n\n```sh\npip install .                    # adds the `jura-connect` console script\npython -m jura_connect discover\n```\n\n## Quickstart\n\n### Pair a new machine (one-time, requires physical access)\n\n```sh\n# 1. Find the machine on your LAN\n$ jura-connect discover\ntcp/51515 open -\u003e 192.168.1.42  (try: jura_connect pair 192.168.1.42)\n\n# 2. Run the pairing flow. The machine will show a \"Connect\" prompt\n#    on its own display; press OK there to accept this device.\n$ jura-connect pair 192.168.1.42 --name Kaffeebert\nconnecting to 192.168.1.42:51515 as conn-id 'jura-connect-7f31a8c2'\nlook at the coffee machine -- a 'Connect' prompt should appear.\n  -\u003e Coffee machine should be showing a 'Connect' prompt — press OK on the machine to accept this device (waiting up to 60s).\nhandshake -\u003e CORRECT  (@hp4:13908FE4...C13156C052)\nmachine type   : EF1091  (discovery)\nsaved credentials for 'Kaffeebert' -\u003e /home/you/.local/share/jura-connect/credentials.json\n```\n\nThe auth-hash is written to `$XDG_DATA_HOME/jura-connect/credentials.json`\nwith `0600` permissions. Override the location with the global\n`--store /path/to.json` flag.\n\n### Machine variants (per-machine profiles)\n\nDifferent Jura models speak the same wire protocol but disagree about\nwhich **product codes** mean what and which **alert bits** map to which\ndisplay strings. The 88 machine XMLs from the J.O.E. APK are bundled\nwith this package and looked up by EF code; pairing tries to detect the\ncode automatically from UDP discovery, but on firmwares that don't\nanswer unicast UDP (notably TT237W) you'll want to pass it explicitly.\n\n```sh\n# Find your machine in the catalogue\n$ jura-connect machine-types --filter \"S8 (EB)\"\n# matches for 'S8 (EB)':\n   15480  S8 (EB)                         EF1091\n   15482  S8 (EB)                         EF1151\n\n# Pair with an explicit machine type\n$ jura-connect pair 192.168.1.42 --name Kaffeebert --machine-type EF1091\n\n# Or retro-fit a machine type onto an already-paired credential\n$ jura-connect set-machine-type --name Kaffeebert EF1091\nset 'Kaffeebert' machine type to EF1091 -\u003e /home/you/.local/share/jura-connect/credentials.json\n\n# Override the stored profile for one invocation\n$ jura-connect command --name Kaffeebert --machine-type EF1091 brews\n```\n\nCredentials without a `machine_type` field fall through to the EF536\nbaseline, so older paired machines keep working without migration.\n\n### Machine name (\"Kaffeebert\")\n\nThe string you see on the touchscreen (and in `jura-connect discover`)\nis the WiFi dongle's display name. It's writable via the gated\n`set-name` command (`@HW:82,\u003cname\u003e`):\n\n```sh\n$ jura-connect command --name Kaffeebert --allow-destructive-commands \\\n    set-name LatteBot\n```\n\nAfter the next reconnect, both the touchscreen and discovery report\nthe new name. There is no separate per-machine display name — Jura's\nWiFi protocol exposes a single name string that the dongle owns and\nthe machine surfaces. The protocol does not expose the\nmachine's local PIN-protected \"machine name\" field (set on the\nmachine itself, behind the service menu); only the dongle's name.\n\n### Run commands against a paired machine\n\nThe CLI exposes a `command` subcommand that takes a *named* read\ncommand, not a raw hex code. Discover the catalog with:\n\n```sh\n$ jura-connect command --list\navailable commands:\n  read-only:\n    info                     full read-only snapshot (status + counters + percent)\n    counters                 maintenance counters (@TG:43)\n    percent                  maintenance percent indicators (@TG:C0)\n    status                   parsed status / active alerts (@HU? -\u003e @TF:)\n    brews                    per-product brew counters (@TR:32 paginated; 16 pages)\n    pmode                    programmable-recipe slots (@TM:50 + @TM:42,\u003cslot\u003e); per-machine\n    setting \u003cname\u003e [\u003cvalue\u003e] read or write a machine setting; profile-aware; write is gated\n    lock                     lock the front-panel display (@TS:01)\n    unlock                   unlock the front-panel display (@TS:00)\n    mem-read \u003caddr\u003e          read a memory/setting slot (@TM:\u003caddr\u003e); firmware-specific\n    register-read \u003cbank\u003e     read a register bank (@TR:\u003cbank\u003e); firmware-specific\n    raw \u003cframe\u003e              send a verbatim '@…' command; payload checked against the destructive set\n\n  destructive (require --allow-destructive-commands; see 'jura-connect command --help'):\n    clean                    [destructive] start coffee-system cleaning cycle (@TG:24)\n    decalc                   [destructive] start descaling cycle (@TG:25)\n    filter-change            [destructive] run water-filter change procedure (@TG:26)\n    cappu-clean              [destructive] start cappuccino-system cleaning (@TG:21)\n    cappu-rinse              [destructive] rinse the milk system (@TG:23)\n    reset-counters           [destructive] zero every maintenance counter (@TG:7E)\n    restart                  [destructive] reboot the WiFi dongle (@TF:02)\n    power-off                [destructive] put the machine into standby (@AN:02)\n    brew \u003crecipe\u003e            [destructive] start brewing a recipe (@TP:\u003crecipe\u003e)\n    set-pin \u003cpin\u003e            [destructive] write a new front-panel PIN (@HW:01,\u003cpin\u003e)\n    set-ssid \u003cssid\u003e          [destructive] write a new WiFi SSID for the dongle (@HW:80,\u003cssid\u003e)\n    set-password \u003cpassword\u003e  [destructive] write a new WiFi password (@HW:81,\u003cpwd\u003e)\n    set-name \u003cname\u003e          [destructive] rename the dongle (@HW:82,\u003cname\u003e)\n```\n\nThe same catalogue is reachable from Python as\n`jura_connect.list_commands()`. Run a command by name:\n\n```sh\n$ jura-connect command --name Kaffeebert info\nhandshake -\u003e CORRECT  (@hp4)\n== machine info ==\n  conn-id        : jura-connect-7f31a8c2\n  handshake state: CORRECT\n  auth-hash      : 13908FE4D3EB986B...\n  status bits    : 0004000008000000\n  errors         : (none)\n  info flags     : coffee_ready, energy_safe\n  process flags  : (none)\n  maintenance    : cleaning=21 filter=1 decalc=8 cappu_rinse=344 coffee_rinse=3617 cappu_clean=91\n  maintenance %  : cleaning=80 filter=255 decalc=30\n\n$ jura-connect command --name Kaffeebert counters\nhandshake -\u003e CORRECT  (@hp4)\ncleaning=21 filter=1 decalc=8 cappu_rinse=344 coffee_rinse=3617 cappu_clean=91\n\n$ jura-connect command --name Kaffeebert status\nhandshake -\u003e CORRECT  (@hp4)\nbits=0004000008000000\n  errors  : (none)\n  info    : coffee_ready, energy_safe\n  process : (none)\n\n$ jura-connect command --name Kaffeebert brews\nhandshake -\u003e CORRECT  (@hp4)\ntotal brews : 3229\n  espresso            : 78\n  coffee              : 595\n  cappuccino          : 64\n  americano           : 1019\n  lungo               : 3\n  espresso_doppio     : 20\n  flat_white          : 210\n  cortado             : 2\n  sweet_latte         : 1\n  2_espressi          : 1\n  2_coffee            : 10\n```\n\nThe product names above are lifted from the S8 EB's own XML\n(`EF1091`). Without a profile the same machine would surface\n`0x2B=2`, `0x2C=1`, `0x31=1`, `0x36=10` as anonymous slots — the EF536\nbaseline doesn't know what those codes brew.\n\nStatus output distinguishes blocking **errors** (machine is stuck,\nuser must act) from **info** flags (low-supply reminders and\nstate-of-being bits such as `no_beans`, `coffee_ready`,\n`energy_safe`) and **process** flags (periodic maintenance prompts\nsuch as `cleaning_alert` and `decalc_alert`). The unsplit\n``active_alerts`` is still on the dataclass for backwards\ncompatibility.\n\nStatus-bit decoding uses **MSB-first** indexing within each byte\n(matching the J.O.E. APK's `Status.a()`). v0.8.0 and earlier used\nLSB-first, which mis-named every bit by 7 positions per byte and\nmade the CLI report e.g. `no_beans` when the live frame actually\nmeant `coffee_ready`. v0.9.0 fixes this; see CHANGELOG for the\ncorrection window.\n\nThe `pmode` command reads the programmable-recipe slot table via\n`@TM:50` + `@TM:42,\u003cslot\u003e`. On the S8 EB / EF1091 every slot returns\n`@tm:C2` (\"not supported by machine\"), and `pmode` surfaces that as\n``not supported by machine`` instead of crashing — useful as a\ndiscriminator between firmware variants:\n\n```sh\n$ jura-connect command --name Kaffeebert pmode\nhandshake -\u003e CORRECT  (@hp4)\npmode: 20 slot(s) reported by @TM:50, but every slot returned C2 (= 'not supported by machine'). This firmware does not expose pmode entries over WiFi.\n```\n\n### Read or write machine settings (`setting`)\n\nEach machine XML declares a `\u003cMACHINESETTINGS\u003e` section listing\nuser-tunable settings (water hardness, auto-off delay, display units,\nlanguage, brightness, milk-rinsing mode, frother instructions). The\n`setting` command reads or writes them by name, using the machine\nprofile to validate the value before going on the wire.\n\n```sh\n# Read a value\n$ jura-connect command --name Kaffeebert setting hardness\nhandshake -\u003e CORRECT  (@hp4)\nhardness = 16 (0x10)\n\n# Substring match is allowed when unambiguous\n$ jura-connect command --name Kaffeebert setting bright\ndisplay_brightness_setting = 40 (0x04)\n\n# Writes are gated. Without the flag, the CLI explains the risk\n# and the catalogue values; with the flag, it validates against the\n# profile (range / step / known item) and computes the @TM:\u003carg\u003e,\u003cval\u003e\n# trailing checksum before sending.\n$ jura-connect command --name Kaffeebert --allow-destructive-commands \\\n    setting language french\nset language = 0x03 (reply: @tm:09)\n\n$ jura-connect command --name Kaffeebert --allow-destructive-commands \\\n    setting hardness 99\nrefused: hardness: 99 is outside [1, 30]\n```\n\nThe catalogue is per-machine: `EF1091` carries 7 settings, other EF\ncodes have different lists. Pair with `--machine-type` (or\n`set-machine-type` after the fact) so the profile is loaded.\n\nThe trailing two hex chars on every write are a checksum the dongle\nverifies — see `_settings_checksum` in `jura_connect.client` and §5.7\nof [`docs/PROTOCOL.md`](docs/PROTOCOL.md). A bad checksum gets\n`@an:error` from the firmware (and from the simulator).\n\nFor one-off advanced use, `raw` echoes any wire command verbatim:\n\n```sh\n$ jura-connect command --name Kaffeebert raw '@TG:43'\nhandshake -\u003e CORRECT  (@hp4)\n@tg:4300150001000801580E21005B\n```\n\n`--watch SECONDS` streams unsolicited `@TF:` (status) and `@TV:`\n(progress) frames; the parsers and the maintenance helpers all just\ncall into the same `JuraClient.request()` / `iter_frames()`.\n\n### JSON output for scripting\n\nPass `--json` and the command's result is emitted on stdout as a JSON\nobject; the handshake banner, watch announcement, watched frames, and\nall error/refusal messages move to stderr so stdout is parseable\nverbatim:\n\n```sh\n$ jura-connect command --name Kaffeebert --json counters | jq .\n{\n  \"name\": \"counters\",\n  \"value\": {\n    \"cleaning\": 21,\n    \"filter_change\": 1,\n    \"decalc\": 8,\n    \"cappu_rinse\": 344,\n    \"coffee_rinse\": 3617,\n    \"cappu_clean\": 91,\n    \"raw_hex\": \"0015000100080158...\"\n  }\n}\n```\n\nComposite values like `info` nest the same way:\n``payload[\"value\"][\"maintenance_counters\"][\"cleaning\"]``. String\nreplies (`lock`, `unlock`, `raw`, the destructive commands' wire\nresponses) come through as ``payload[\"value\"]`` directly. Every\nstructured result type — `MaintenanceCounters`, `MaintenancePercent`,\n`MachineStatus`, `MachineInfo`, `CommandResult` — exposes the same\n`to_dict()` from Python.\n\n### Destructive commands (gated)\n\nCommands that change the machine's physical state — start cleaning\ncycles, brew product, reset counters, write WiFi credentials or the\nmachine PIN — live in the same registry but are refused by default\n*before* anything is sent. The error you get spells out the risk:\n\n```sh\n$ jura-connect command --name Kaffeebert clean\nhandshake -\u003e CORRECT  (@hp4)\nrefused: 'clean' is a destructive command — starts a real cleaning\ncycle (~5 min) that consumes a cleaning tablet and locks the machine\nuntil the cycle finishes. There is no remote 'abort'.\nRe-run with --allow-destructive-commands (CLI) or\nallow_destructive=True (library) if you really mean it.\n```\n\nPass `--allow-destructive-commands` once you've read what the command\ndoes and have any required supplies / containers / cups in place:\n\n```sh\n$ jura-connect command --name Kaffeebert --allow-destructive-commands clean\n```\n\nThe list of gated wire prefixes (`@TG:21/23/24/25/26/7E/FF`, `@TF:02`,\n`@AN:02`, `@TP:`, `@HW:`) is exported as\n`jura_connect.DESTRUCTIVE_PREFIXES`. The `raw` escape hatch inspects its\nargument against the same list, so `command raw '@TG:24'` is gated\ntoo — the bypass cannot be used by accident.\n\nWrong values for `set-pin` / `set-ssid` / `set-password` can leave you\nlocked out of the machine or unable to reach the dongle over WiFi;\nthe only recovery is a **factory reset on the machine itself**.\n`reset-counters` is **irreversible** — there is no way to learn back\nwhen the machine was last serviced once it's been zeroed.\n\n### List / remove stored credentials\n\n```sh\n$ jura-connect creds\n# /home/you/.local/share/jura-connect/credentials.json\nKaffeebert            192.168.1.42     conn-id=jura-connect-7f31a8c2  hash=13908FE4D3EB986B...  paired_at=2026-05-11T08:42:00Z\n\n$ jura-connect creds --delete Kaffeebert\nremoved 'Kaffeebert' from .../credentials.json\n```\n\n## Library API\n\n```python\nfrom jura_connect import (\n    JuraClient, CredentialStore, MachineCredentials,\n    discover, run_named, list_commands,\n)\n\n# Discovery\nfor m in discover(timeout=4.0):\n    print(m.name, m.fw, m.address)\n\n# First-time pair (requires user to press OK on the machine)\nclient = JuraClient(\"192.168.1.42\", conn_id=\"laptop-1\")\nresult = client.pair(timeout=60.0,\n                     on_user_prompt=lambda msg: print(msg))\nprint(result.state)        # \"CORRECT\"\nprint(result.new_hash)     # 64-hex-char auth token\n\n# Persist\nstore = CredentialStore()\nstore.put(MachineCredentials(\n    name=\"Kaffeebert\",\n    address=\"192.168.1.42\",\n    conn_id=\"laptop-1\",\n    auth_hash=result.new_hash,\n))\nclient.close()\n\n# Reconnect later from disk and run named commands\ncreds = store.get(\"Kaffeebert\")\nwith JuraClient(creds.address, conn_id=creds.conn_id,\n                auth_hash=creds.auth_hash) as c:\n    # Either the high-level helpers …\n    info = c.read_machine_info()\n    print(info.maintenance_counters)   # MaintenanceCounters(cleaning=21, ...)\n    print(info.status.active_alerts)   # ('coffee_ready', 'energy_safe')\n\n    # … or the named-command registry — same API the CLI uses:\n    for spec in list_commands():\n        print(spec.usage(), \"—\", spec.description)\n    result = run_named(c, \"counters\")\n    print(result.format())             # cleaning=21 filter=1 decalc=8 …\n```\n\n## Tests, lint, and type-check\n\nThe package's build derivation runs **all three** as a single QA gate:\n\n```sh\n# Builds the package; preBuild runs ruff + ty, then pytest runs in\n# the install-check phase. One command, no separate invocations.\nnix build .#default --print-build-logs\n\n# Same derivation, called as a \"flake check\" — identical behaviour.\nnix flake check\n```\n\nConcretely the gate is:\n\n1. `ruff check jura_connect/ tests/` — lint.\n2. `ruff format --check jura_connect/ tests/` — formatting drift.\n3. `ty check jura_connect/` — Astral's type checker on the library.\n4. `pytest tests/ -q` — the 340-case test suite against the in-tree\n   simulator, including 88-XML profile-registry coverage.\n\nIf you want to run any one of them ad-hoc without the whole build,\nenter the dev shell (`nix develop`) which has all four tools on\n`$PATH`, then run them directly. The [GitHub Actions workflow](./.github/workflows/ci.yml)\nruns `nix build .#default` on every push and PR, so the badge at the\ntop of this README turns green only when all four steps pass.\n\nThe test-suite covers:\n\n* every byte value of the cipher key (`test_crypto.py`),\n* discovery-reply parsing including the unusual MSB-counted bit checks\n  (`test_discovery.py`),\n* every handshake state via the simulator + a tiny one-shot socket\n  server for the garbage-reply path (`test_handshake.py`),\n* every read command and the simulator's destructive-command guardrail\n  (`test_reads.py`),\n* the JSON credential round-trip plus a full pair→persist→reconnect\n  workflow (`test_credentials.py`),\n* every entry of the named-command registry round-tripped through the\n  simulator, plus error paths (`test_commands.py`),\n* the 88-XML profile registry — every bundled machine parses cleanly,\n  EF1091 surfaces its S8 EB-specific product codes, alert severities\n  follow the XML's `ALERT.Type` attribute (`test_profile.py`),\n* CLI smoke tests for `command --list`, `command info` against the\n  simulator, the `machine-types` / `set-machine-type` subcommands,\n  and credential-store interactions (`test_cli.py`).\n\n## Versioning\n\nThis project follows [Semantic Versioning](https://semver.org/). See\n[`CHANGELOG.md`](CHANGELOG.md) for the release history; the current\nversion is also exposed as `jura_connect.__version__` and `jura-connect --version`.\n\n## Releasing\n\nCutting a release is a CLI flow — no clicking around the GitHub UI:\n\n```sh\n# 1. Bump the version in the three places it lives, and add a\n#    CHANGELOG entry. ./jura_connect/__init__.py, pyproject.toml,\n#    flake.nix.\n$EDITOR jura_connect/__init__.py pyproject.toml flake.nix CHANGELOG.md\n\n# 2. Verify locally — this is the same gate CI runs.\nnix build .#default --print-build-logs\n\n# 3. Commit and push.\ngit add -A\ngit commit -m \"jura-connect: release vX.Y.Z\"\ngit push\n\n# 4. Tag and push the tag.\ngit tag -a vX.Y.Z -m \"vX.Y.Z\"\ngit push origin vX.Y.Z\n\n# 5. Create the GitHub release. Use --notes-file to feed the\n#    matching CHANGELOG section straight in.\nawk '/^## \\[X\\.Y\\.Z\\]/,/^## \\[/{ if (/^## \\[/ \u0026\u0026 !/X\\.Y\\.Z/) exit; print }' \\\n    CHANGELOG.md \u003e /tmp/notes.md\ngh release create vX.Y.Z --title \"vX.Y.Z\" --notes-file /tmp/notes.md\n```\n\nPublishing the GitHub release triggers the\n[`publish` workflow](./.github/workflows/publish.yml), which:\n\n1. re-runs `nix build .#default` against the tag (so a stale or\n   broken tag cannot ship);\n2. builds the sdist + wheel with `python -m build`;\n3. uploads to PyPI via [trusted publishing](https://docs.pypi.org/trusted-publishers/)\n   (OIDC — no long-lived API token in repo secrets).\n\n### One-time PyPI setup\n\nBefore the first PyPI upload succeeds, register this repo as a\ntrusted publisher at\n\u003chttps://pypi.org/manage/account/publishing/\u003e with:\n\n| Field            | Value                              |\n| ---------------- | ---------------------------------- |\n| PyPI Project name | `jura_connect`                    |\n| Owner            | `makefu`                           |\n| Repository name  | `jura-connect`                     |\n| Workflow name    | `publish.yml`                      |\n| Environment name | `pypi`                             |\n\nAfter registering, create a GitHub environment called `pypi` on the\nrepo (Settings → Environments → New environment) to match the\nworkflow's `environment.name`.\n\n### Manual fallback (no CI)\n\nIf GitHub Actions is unavailable, the same artefacts can be built\nand uploaded by hand. Use `python -m build` (the pypa standard) plus\ntwine — works on any Python 3.11+:\n\n```sh\npython -m pip install --upgrade build twine\npython -m build --sdist --wheel --outdir dist/\ntwine check dist/*\ntwine upload dist/*    # prompts for credentials\n```\n\nOr as a one-shot `nix-shell` if you'd rather not touch the system\nPython:\n\n```sh\nnix-shell -p 'python313.withPackages(ps: [ ps.build ])' \\\n          -p python313Packages.twine \\\n          --run '\n    python -m build --sdist --wheel --outdir dist/\n    twine check dist/*\n    twine upload dist/*\n  '\n```\n\n## Protocol reference\n\nSee [`docs/PROTOCOL.md`](docs/PROTOCOL.md) for the technical workflow\ndescription (wire framing, handshake state-machine, command catalogue,\nknown unknowns). This document is the source of truth for the\nimplementation and was used to validate every code path against the\nAndroid APK and against Kaffeebert.\n\n## Acknowledgements\n\nThe Bluetooth and UART flavours of the Jura control protocol were\nreverse-engineered first by the **[Jutta-Proto](https://github.com/Jutta-Proto)**\nproject — most notably:\n\n* [`Jutta-Proto/protocol-bt-cpp`](https://github.com/Jutta-Proto/protocol-bt-cpp)\n  — C++ Bluetooth implementation for the BlueFrog dongle. Their write-up\n  of the obfuscation / encoding scheme, the `@HP:` handshake, and the\n  destructive command set was the starting point for understanding the\n  shared \"Jura control language\" that the WiFi dongle also speaks.\n* [`Jutta-Proto/protocol-cpp`](https://github.com/Jutta-Proto/protocol-cpp)\n  — C++ UART implementation, which in turn builds on the earlier\n  [Protocol JURA wiki](http://protocoljura.wiki-site.com/index.php/Hauptseite)\n  community work for older serial-only models.\n\nThis project is an independent port targeting the *WiFi* transport\n(`Smart Connect` dongle, TT237W firmware family) and was developed by\nreading the J.O.E. Android APK and validating against a physical S8 EB.\nThe framing, cipher, and handshake match what the Jutta-Proto repos\ndescribe; the differences live in the transport (TCP/51515 instead of\nGATT characteristics) and in the WiFi-specific discovery and pairing\nhandshake.\n\nWithout the Jutta-Proto work the project would not have started in first place.\n\n## Usage of LLMs\n\nThis project has been 100% written by the Claude Code Model \"Opus 4.7\" starting 2026-05-11\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmakefu%2Fjura-connect","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmakefu%2Fjura-connect","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmakefu%2Fjura-connect/lists"}