Skip to content

Keep the machine connection alive across system standby - #2225

Open
edwin-truthsearch-io wants to merge 1 commit into
artisan-roaster-scope:masterfrom
edwin-truthsearch-io:macos-connection-robustness
Open

edwin-truthsearch-io wants to merge 1 commit into
artisan-roaster-scope:masterfrom
edwin-truthsearch-io:macos-connection-robustness

Conversation

@edwin-truthsearch-io

@edwin-truthsearch-io edwin-truthsearch-io commented Aug 27, 2026

Copy link
Copy Markdown

Addresses #2226. Note that this PR fixes the standby part of that report, not the second aspect described there — a Bluetooth serial device node that no longer exists after the link was torn down cannot be re-opened by Artisan — so the issue should stay open after a merge.

Related to #1705 (macOS Bluetooth SPP connection loss with Kaleido machines). That report is about not being able to reconnect between roasts, which this PR does not claim to fix; it addresses the neighbouring symptom of the link being torn down during a roast, and the forced reconnect and the connection logging added here should help with the analysis of that report too.

Problem

On a laptop the machine connection is lost towards the end of a roast. macOS enters idle standby after the user did not touch the keyboard for a while (the roast is running, but Artisan generates no input events), which tears down the USB/Bluetooth link. Machines that watch the communication react on that connection loss — on a Kaleido connected over Bluetooth serial the machine drops out of the roast into cooling, which ruins the batch.

Nothing in Artisan inhibited the system sleep so far. The only power related call is appnope.nope() at startup, which uses NSActivityUserInitiatedAllowingIdleSystemSleep and thus explicitly permits the idle system sleep.

What changed

artisanlib/power.py (new)

  • SleepInhibitor prevents the idle system sleep while Artisan is ON:
    • macOS: an NSProcessInfo activity with NSActivityUserInitiated | NSActivityLatencyCritical, which also disables App Nap and the timer coalescing that otherwise delays the sampling of an app whose display is asleep. Falls back to an IOPMAssertionCreateWithName(PreventUserIdleSystemSleep) assertion via ctypes if pyobjc is unavailable (running from source).
    • Windows: SetThreadExecutionState(ES_CONTINUOUS|ES_SYSTEM_REQUIRED|ES_AWAYMODE_REQUIRED), retried without away mode if unsupported.
    • Linux: best effort systemd-inhibit --what=idle:sleep.
    • The display is still allowed to sleep — only the system has to stay awake, so the battery impact stays small.
  • WakeDetector recognizes a system suspension that could not be inhibited (a laptop closing its lid, or an explicit sleep) by the divergence of the wall clock and the monotonic clock.

Wiring (canvas.py): acquired in OnMonitor, released in OffMonitorCloseDown. A 5s QTimer runs the wake detection while sampling; on wake Artisan logs the suspension and reconnects the machine.

Reconnect on wake: AsyncComm.reconnect() and KaleidoPort.reconnect() close the current writer through the asyncio loop so the existing connect loop re-establishes the connection right away (~0.5–1s, re-sending Kaleido's SC AR guard). This matters because a connection established before a suspension can be dead without the transport ever reporting an error: AsyncComm.handle_reads() loops on while not reader.at_eof() with no timeout, so such a connection could hang forever. The classic pyserial devices re-open their port on the next failing read and the BLE devices reconnect via their disconnect callbacks, so both are deliberately left alone.

Setting: a Prevent Sleep flag in the Sampling dialog, default ON, persisted as KeepAwake, applied immediately when toggled while ON.

Connection logging: the connect/connected/connection-lost/timeout messages of the Kaleido and AsyncComm transports were all _log.debug while the artisanlib file handler logs at INFO. Raw exceptions do reach artisan.log (via the generic _log.error(e) of the connect loops), but there is no record of when a connection was established or lost, which makes it hard to tell a failed reconnect apart from a connection that never came up. Those lifecycle messages are now INFO/WARNING, and the SerialException that AsyncComm.connect() swallowed silently is logged too. Repeated retries do not flood the log — DuplicateFilter collapses them.

Fix: a lost connection is now reported once instead of on every failing reconnect attempt (the Kaleido serial loop emitted Kaleido disconnected every 0.5s while the machine was unreachable; AsyncComm never reset its was_connected flag although its comment says it reports ONE message).

Verification

  • 41 new unit tests (test_power.py plus additions to test_canvas.py, test_kaleido.py, test_async_comm.py) covering the inhibitor state machine, the platform dispatch and failure paths, both real macOS backends, the wake detection incl. its false positive cases (long/irregular check intervals, sub-threshold gaps), the reconnect methods and the ON/OFF wiring.
  • Full suite: 2413 passed, 7 skipped.
  • ruff clean, pylint 10.00/10, mypy and pyright report nothing on the changed lines, codespell clean.
  • Both macOS backends verified on a real machine: the assertion shows up in pmset -g assertions as PreventUserIdleSystemSleep named: "Artisan is connected to a roasting machine" while ON and disappears on OFF. The change was also built into an app bundle with artisan-mac.spec to confirm that PyInstaller picks up the lazily imported artisanlib.power.

Please note what is not verified on real hardware: I have no Windows or Linux machine. The Windows backend is covered by tests against a faked ctypes.windll (flags, the away-mode retry, the platform dispatch) and the Linux one only by the platform dispatch, so both had a code review but no run. A smoke test on Windows would be welcome, in particular that the machine stays awake with Artisan ON and sleeps normally again after OFF.

The parts that affect every platform are the transport changes: AsyncComm and KaleidoPort now track the current writer and report a lost connection once instead of per retry attempt, which touches all machines using those transports (Santoker, Hottop, Mugma, Orbiter, ColorTrack, Acaia, Kaleido), and the Prevent Sleep default of ON applies everywhere.

Notes for reviewers

  • The default is ON. Whoever wants the machine to be able to sleep while Artisan is ON has to uncheck Prevent Sleep.
  • Clamshell sleep (closing the lid) cannot be inhibited by an application — that is what the wake detection and the reconnect are for.
  • Out of scope, but found on the way and filed separately as Roast timer does not advance while the system is suspended, shortening the recorded timeline #2227: ArtisanTime uses time.perf_counter(), which does not advance while the system is suspended, so a suspension during a recording silently shortens the roast timeline. The wake warning in the log points this out; changing the clock base would affect every recording and did not belong in this PR.
  • The GUI wiring itself was not exercised in a real roast, only by the tests and by inspection.

A system standby tears down the USB/Bluetooth link to the machine. Machines
watching the communication (like Kaleido) react on that connection loss, on a
laptop typically towards the end of a roast when the user did not touch the
keyboard for a while.

- adds artisanlib/power.py with a SleepInhibitor preventing the idle system
  sleep while Artisan is ON (macOS: NSProcessInfo activity, which also disables
  App Nap and the timer coalescing that delays sampling while the display is
  off, with an IOKit power assertion as fallback; Windows:
  SetThreadExecutionState; Linux: systemd-inhibit). The display is still
  allowed to sleep.
- adds the 'Prevent Sleep' flag (default ON) to the Sampling dialog
- adds a WakeDetector recognizing system suspensions that cannot be inhibited
  (eg. a laptop closing its lid) and reconnects the machine on wake via the new
  AsyncComm.reconnect()/KaleidoPort.reconnect(), both sharing the new
  force_reconnect(), as a connection established before a suspension can be
  dead without the transport ever reporting an error
- reports a lost connection only once instead of on every failing reconnect
- logs the connection lifecycle (connect, connected, connection lost, timeout,
  serial exception) on the default log level to ease the analysis of connection
  issues

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant