discstation 0.1.35 → 0.1.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -46,8 +46,7 @@ remote can talk to the host over Wi-Fi instead of a USB cable.
46
46
  |------|-------------|
47
47
  | **Burn Video DVD** | YouTube URL or local file → ffmpeg 2-pass → DVD-Video disc |
48
48
  | **Burn Data DVD** | Any files/folders → ISO/Joliet data disc (no quality loss) |
49
- | **Burn MPG** | Re-burn a previously converted movie.mpg |
50
- | **Play** | Playback via mpv (DVD-Video, Audio CD, VCD, SVCD) — the OLED remote shows a live spectrum visualizer of the actual audio (Linux only for now; see `docs/PLATFORM_SUPPORT.md`) |
49
+ | **Play** | Playback via mpv (DVD-Video, Audio CD, VCD, SVCD) — the OLED remote shows a live spectrum visualizer of the actual audio (Linux only for now; see `docs/PLATFORM_SUPPORT.md`); while a track plays the OLED keeps the track/volume screen (or the visualizer) up — the spinning-disc screensaver only appears on the home/standby screens or after playback has been paused for 15 s |
51
50
  | **Rip** | Audio CD → FLAC (MusicBrainz); DVD-Video → VIDEO_TS mirror or HandBrake MKV (TMDb naming) |
52
51
 
53
52
  ## Project Structure
@@ -63,6 +62,7 @@ remote can talk to the host over Wi-Fi instead of a USB cable.
63
62
  ├── mobile/ # Expo (React Native) companion app
64
63
  ├── arduino/DiscStation/ # ESP32 DevKit remote firmware
65
64
  ├── scripts/setup.mjs # `discstation-setup` — cross-OS installer dispatch
65
+ ├── scripts/check.sh # static checks + smoke test (run before committing)
66
66
  ├── docs/ # Platform support notes
67
67
  ├── install.sh / install-macos.sh / install-windows.ps1
68
68
  ├── requirements.txt / requirements-optional.txt
@@ -94,7 +94,9 @@ dock/taskbar icon on macOS, Linux, and Windows.
94
94
  **Updating:** `npm install -g discstation` alone now redeploys and restarts
95
95
  the already-running service automatically (a `postinstall` hook copies the
96
96
  updated files over and restarts it) — no need to re-run `discstation-setup`
97
- after every update. `discstation-setup` itself is still what performs the
97
+ after every update. Note this deploys whatever version is *published*, so installing an
98
+ older published release over a newer git checkout rolls the running service
99
+ back to it until you publish (or re-copy `src/`) again. `discstation-setup` itself is still what performs the
98
100
  first install (system packages, venv, cert, service registration).
99
101
 
100
102
  Support by OS:
@@ -154,10 +156,11 @@ self-signed cert. On startup the host prints the LAN URL to open
154
156
  ## Mobile App
155
157
 
156
158
  The `mobile/` Expo app (enter the host's LAN address in its settings screen)
157
- has the same on-screen remote as the web UI — mode buttons, EJECT/CLOSE,
158
- disc status, and playback transport, tapped from the "REMOTE" tag next to
159
- the wordmark. Same auto-collapse behavior when a physical ESP32 shows up,
160
- same underlying `/remote/button` protocol.
159
+ has the same on-screen remote as the web UI, rendered inline in the main
160
+ screen rather than a popup — same "REMOTE" tag toggle, opens automatically
161
+ with no ESP32 attached, mode buttons filtered to what the disc actually
162
+ supports, and picking one starts it directly. Same auto-collapse behavior
163
+ when a physical ESP32 shows up, same underlying `/remote/button` protocol.
161
164
 
162
165
  ## Disc Support
163
166
 
@@ -33,7 +33,6 @@
33
33
  #define LONG_PRESS_MS 1000
34
34
  #define ENC_CLICK_GUARD_MS 200 // ignore new SW presses this soon after a rotation tick (rotation vibration can bounce SW low)
35
35
  #define DONE_RESET_MS 30000
36
- #define STANDBY_BLANK_MS 60000
37
36
  #define IDLE_BLANK_MS 15000 // no input this long on HOME/STANDBY -> spinning-disc screensaver
38
37
  // (this power bank's no-load auto-shutoff trips at ~30s idle; keep this
39
38
  // well under that so the screensaver's current draw beats it there)
@@ -222,6 +221,8 @@ String discLine = "Disc: none";
222
221
  String line1 = "Status: READY";
223
222
  String line2 = "";
224
223
  String line3 = "";
224
+ bool trayOpen = false; // host says the tray is open (eject wait / STANDBY:Tray open) - EJECT button then closes it
225
+ bool waitingIsTray = false; // the WAITING screen is the eject "close tray" wait, not the pre-burn confirm
225
226
  String waitingLine1 = "";
226
227
  String waitingLine2 = "";
227
228
  int progressPercent = -1;
@@ -272,6 +273,7 @@ uint8_t vuLevel[VU_BARS];
272
273
  bool visualizerActive = false;
273
274
  unsigned long lastVuAt = 0;
274
275
  unsigned long vuSuppressUntil = 0; // bars withheld (text screen shown instead) until millis() reaches this
276
+ bool playPaused = false; // last PLAY_STATUS was PAUSED - the only PLAY state the idle screensaver may take over
275
277
  bool vuSeenReal = false; // has this PLAY session ever gotten a VU: frame with a nonzero bar -
276
278
  // a session-level fact (macOS: never; Linux: within the first frame or
277
279
  // two), decided once and left alone. Deliberately separate from
@@ -456,7 +458,7 @@ void drawWaiting() {
456
458
  printUpper(waitingLine2);
457
459
 
458
460
  display.setCursor(2, 49);
459
- display.print("SHORT // GO");
461
+ display.print(waitingIsTray ? "EJECT BTN // CLOSE" : "SHORT // GO");
460
462
 
461
463
  display.display();
462
464
  }
@@ -774,6 +776,7 @@ void parseMessage(String msg) {
774
776
  Out.println(msg);
775
777
 
776
778
  if (msg.startsWith("HOME:")) {
779
+ trayOpen = false;
777
780
  drawHome();
778
781
 
779
782
  } else if (msg.startsWith("DISC:")) {
@@ -802,6 +805,7 @@ void parseMessage(String msg) {
802
805
  if (uiState == UI_HOME) drawHome();
803
806
 
804
807
  } else if (msg.startsWith("STANDBY:")) {
808
+ trayOpen = msg.substring(8) == "Tray open";
805
809
  drawStandby();
806
810
 
807
811
  } else if (msg.startsWith("TITLE:")) {
@@ -827,6 +831,9 @@ void parseMessage(String msg) {
827
831
  lastVuAt = 0; // fresh session - unknown yet whether the host even sends VU: at all
828
832
  visualizerActive = false;
829
833
  vuSeenReal = false; // unknown yet whether this session ever gets a real (nonzero) frame
834
+ playPaused = false;
835
+ displayBlank = false; // a fresh session must never inherit a running screensaver
836
+ lastInputTime = millis();
830
837
  Out.print("POT:"); // push the last-used volume so playback starts at it
831
838
  Out.println(playVolume);
832
839
  drawPlay();
@@ -838,6 +845,9 @@ void parseMessage(String msg) {
838
845
  } else if (msg.startsWith("PLAY_STATUS:")) {
839
846
  line1 = msg.substring(12);
840
847
  if (line1.length() > 20) line1 = line1.substring(0, 20);
848
+ playPaused = (line1 == "PAUSED");
849
+ if (playPaused) lastInputTime = millis(); // idle timer starts from the pause
850
+ else displayBlank = false; // resumed (even from the web remote) - drop the screensaver
841
851
  if (line1 == "PLAYING" || line1 == "PAUSED" || (audioPlayMode && line1.startsWith("TRACK "))) {
842
852
  playStatusTemp = false;
843
853
  } else {
@@ -849,8 +859,13 @@ void parseMessage(String msg) {
849
859
  } else if (msg.startsWith("WAITING:")) {
850
860
  returnToHomeAt = 0;
851
861
  String rest = msg.substring(8);
862
+ waitingIsTray = rest.indexOf("close tray") >= 0;
863
+ if (waitingIsTray) trayOpen = true;
852
864
  int slash = rest.indexOf('/');
853
- if (slash >= 0) {
865
+ if (waitingIsTray) {
866
+ waitingLine1 = "TRAY OPEN";
867
+ waitingLine2 = "";
868
+ } else if (slash >= 0) {
854
869
  waitingLine1 = "Data: " + rest.substring(0, slash);
855
870
  waitingLine1.trim();
856
871
  waitingLine2 = "Disc: " + rest.substring(slash + 1);
@@ -983,14 +998,6 @@ void handleSelectPress(bool longPress) {
983
998
  drawStatus();
984
999
  }
985
1000
 
986
- } else if (uiState == UI_STANDBY) {
987
- Out.println("EJECT");
988
- line1 = "Ejecting...";
989
- line2 = "";
990
- line3 = "";
991
- returnToHomeAt = millis() + 5000;
992
- drawStatus();
993
-
994
1001
  } else if (uiState == UI_STATUS && line1 == "Ejecting...") {
995
1002
  // Manual escape if stuck on eject screen with no app response
996
1003
  drawStandby();
@@ -1005,7 +1012,7 @@ void handleSelectPress(bool longPress) {
1005
1012
  if (longPress) {
1006
1013
  Out.println("CANCEL");
1007
1014
  drawHome();
1008
- } else {
1015
+ } else if (!waitingIsTray) { // tray close moved to the EJECT button
1009
1016
  Out.println("CONFIRM");
1010
1017
  line1 = "Confirmed!";
1011
1018
  line2 = "";
@@ -1138,7 +1145,15 @@ void handleEncoderCCW() {
1138
1145
  void handleEjectButton() {
1139
1146
  if (wakeDisplay()) { lastInputTime = millis(); return; }
1140
1147
  lastInputTime = millis();
1141
- if (uiState == UI_HOME || uiState == UI_STANDBY || uiState == UI_DISCONNECTED || uiState == UI_PLAY) {
1148
+ if (trayOpen && (uiState == UI_STANDBY || uiState == UI_HOME || (uiState == UI_WAITING && waitingIsTray))) {
1149
+ Out.println("CONFIRM"); // host closes the tray
1150
+ trayOpen = false;
1151
+ line1 = "Closing tray...";
1152
+ line2 = "";
1153
+ line3 = "";
1154
+ returnToHomeAt = millis() + 5000;
1155
+ drawStatus();
1156
+ } else if (uiState == UI_HOME || uiState == UI_STANDBY || uiState == UI_DISCONNECTED || uiState == UI_PLAY) {
1142
1157
  Out.println("EJECT");
1143
1158
  line1 = "Ejecting...";
1144
1159
  line2 = "";
@@ -1353,24 +1368,13 @@ void loop() {
1353
1368
  if (uiState == UI_PLAY && !displayBlank) drawPlay();
1354
1369
  }
1355
1370
 
1356
- // --- Idle disc screensaver (HOME + STANDBY + PLAY) ---
1357
- // PLAY is included because a static "PLAYING" screen is just as low-current
1358
- // as HOME/STANDBY were - the power bank doesn't care what's on screen, only
1359
- // that the draw stays static this long.
1360
- //
1361
- // In PLAY specifically, if this session has never gotten a single REAL
1362
- // (nonzero) VU: frame (!vuSeenReal - the host/platform doesn't support the
1363
- // visualizer, e.g. macOS today), don't make the user wait out the full
1364
- // generic idle timer for an animation - drop into the spinning-disc
1365
- // screensaver as soon as the text-hold window (vuSuppressUntil) expires.
1366
- // vuSeenReal (not lastVuAt, which now updates on every frame including
1367
- // all-zero ones) is deliberately a session-level, decided-once fact, so
1368
- // this can't re-trigger mid-playback and flicker against the bars.
1369
- bool playSkippingToScreensaver = (uiState == UI_PLAY) && !vuSeenReal &&
1370
- (long)(millis() - vuSuppressUntil) >= 0;
1371
+ // --- Idle disc screensaver (HOME + STANDBY, or PLAY while paused) ---
1372
+ // Never while a track is actually playing - the track/volume status (or
1373
+ // visualizer) owns the screen then. A static screen is what the power bank
1374
+ // needs to see stay drawing, so the saver covers every idle/paused state.
1371
1375
  if (displayOk && !displayBlank &&
1372
- (uiState == UI_HOME || uiState == UI_STANDBY || uiState == UI_PLAY) &&
1373
- ((long)(millis() - lastInputTime) >= IDLE_BLANK_MS || playSkippingToScreensaver)) {
1376
+ (uiState == UI_HOME || uiState == UI_STANDBY || (uiState == UI_PLAY && playPaused)) &&
1377
+ (long)(millis() - lastInputTime) >= IDLE_BLANK_MS) {
1374
1378
  displayBlank = true;
1375
1379
  saverStep = 0;
1376
1380
  lastSaverFrame = 0;
@@ -20,7 +20,7 @@ DISC_DL_OUTPUT_LIMIT_BYTES=8000000000
20
20
  # ~/.local/share/discstation/tmdb.key
21
21
  # DISCSTATION_TMDB_API_KEY=
22
22
  # YTDLP_COOKIES_FROM_BROWSER=firefox
23
- # YTDLP_COOKIES=/home/phuju/.config/discstation/youtube-cookies.txt
23
+ # YTDLP_COOKIES=~/.config/discstation/youtube-cookies.txt
24
24
  # YTDLP_USER_AGENT=Mozilla/5.0 ...
25
25
  # YTDLP_PO_TOKEN=web_embedded.gvs+TOKEN
26
26
  # YTDLP_EXTRACTOR_ARGS=youtube:player_client=web_embedded
@@ -27,37 +27,19 @@ Set `DISC_DEVICE` if automatic drive detection fails. The one known limitation
27
27
  is **audio-CD *burning*** — `cdrdao` is the only option and often cannot claim
28
28
  the drive on recent macOS; DiscStation reports this clearly instead of hanging.
29
29
 
30
- ### ESP32 remote: OLED spectrum visualizer — not currently working on macOS
31
-
32
- **Confirmed broken, not just a setup step.** The visualizer's macOS capture
33
- path (`ffmpeg -f avfoundation` reading the BlackHole loopback device) was
34
- built and the BlackHole/Multi-Output-Device routing was set up and verified
35
- correct on real hardware — but `ffmpeg`'s capture consistently returns
36
- silence (valid-looking output, zero errors, every sample exactly `0`) even
37
- with the native macOS Sound input level meter confirmed showing real,
38
- moving signal on BlackHole at the same moment. That combination — real
39
- signal present at the OS/driver level, silently zeroed once it reaches an
40
- app's capture buffer — is macOS's system-audio-capture privacy protection
41
- muting an untrusted capturer, not a permission checkbox that was missed:
42
- Microphone and Screen & System Audio Recording were both granted to
43
- `ffmpeg` with no change.
44
-
45
- The legitimate modern API for this (CoreAudio's Process Tap,
46
- `AudioHardwareCreateProcessTap`, macOS 14.2+) requires a properly
47
- code-signed app bundle requesting a specific entitlement, with its own
48
- dedicated system consent dialog — a bare ad-hoc-signed Homebrew CLI binary
49
- like `ffmpeg` structurally cannot satisfy that, regardless of which Privacy
50
- & Security toggles are flipped. Making the visualizer work on macOS would
51
- mean building a small signed helper app around that API — a real, separate
52
- project, not started.
53
-
54
- **Everything else works normally** — audio-CD/DVD/video playback, ripping,
55
- burning, and the web UI are all unaffected. PLAY just always shows the
56
- normal text status screen on macOS instead of ever switching to bars.
57
-
58
- If BlackHole is already installed from an earlier attempt at this, it's
59
- harmless to leave in place — it just won't do anything useful for
60
- DiscStation until/unless the above gets built.
30
+ ### ESP32 remote: OLED spectrum visualizer — Linux only
31
+
32
+ The bars need a way to tap the audio being played. On Linux that's PulseAudio's
33
+ monitor source (`parec`). macOS has no equivalent a plain CLI tool can use: a
34
+ capture through a loopback driver (BlackHole + `ffmpeg -f avfoundation`) was
35
+ built and verified end to end on real hardware, and returns pure silence
36
+ (every sample `0`, no errors) even while the native Sound input meter shows
37
+ signal — macOS's system-audio-capture privacy protection mutes untrusted
38
+ capturers, and no Privacy & Security toggle changes that. The supported route
39
+ is CoreAudio's Process Tap (`AudioHardwareCreateProcessTap`, macOS 14.2+),
40
+ which needs a code-signed app bundle with its own consent dialog — a separate
41
+ project, not started, so the capture code was removed. PLAY just shows the
42
+ normal text status screen on macOS; everything else is unaffected.
61
43
 
62
44
  ### Troubleshooting playback ("mpv not found" / play fails to start)
63
45
 
package/install-macos.sh CHANGED
@@ -28,7 +28,7 @@ if ! command -v brew >/dev/null 2>&1; then
28
28
  fi
29
29
 
30
30
  brew install python ffmpeg cdrdao dvdauthor node yt-dlp xorriso mpv libdiscid handbrake \
31
- libdvdcss dvdbackup libcdio-paranoia blackhole-2ch
31
+ libdvdcss dvdbackup libcdio-paranoia
32
32
  mkdir -p "$APP_DIR" "$VENV_DIR" "$CONFIG_DIR"
33
33
  cp -R "$ROOT_DIR/src/." "$APP_DIR/"
34
34
  python3 -m venv "$VENV_DIR"
@@ -40,12 +40,9 @@ else
40
40
  fi
41
41
  # Optional metadata deps — best effort. python-libdiscid (a C extension) is
42
42
  # Linux-only in requirements-optional.txt; on macOS install the pure-Python
43
- # bits and let brew's libdiscid cover disc IDs via the CLI tools. numpy is
44
- # for the OLED spectrum visualizer (needs the blackhole-2ch brew package
45
- # above too, plus a one-time Multi-Output Device setup - see
46
- # docs/PLATFORM_SUPPORT.md).
43
+ # bits and let brew's libdiscid cover disc IDs via the CLI tools.
47
44
  if [[ -f "$ROOT_DIR/requirements-optional.txt" ]]; then
48
- "$VENV_DIR/bin/python" -m pip install musicbrainzngs tmdbsimple numpy \
45
+ "$VENV_DIR/bin/python" -m pip install musicbrainzngs tmdbsimple \
49
46
  || printf 'Optional metadata deps skipped (host still works).\n'
50
47
  fi
51
48
 
package/install.sh CHANGED
@@ -14,7 +14,8 @@ fi
14
14
  if command -v apt-get >/dev/null 2>&1; then
15
15
  sudo apt-get update
16
16
  sudo apt-get install -y \
17
- cdrdao dvdauthor ffmpeg genisoimage growisofs handbrake-cli lsdvd mpv \
17
+ cdrdao dvdauthor dvdbackup eject ffmpeg genisoimage growisofs handbrake-cli \
18
+ lsdvd mpv sg3-utils \
18
19
  nodejs openssl python3 python3-pip python3-serial python3-mutagen \
19
20
  python3-requests python3-pyudev python3-libdiscid python3-musicbrainzngs \
20
21
  python3-numpy pulseaudio-utils libdiscid0 python3-venv wodim
@@ -51,6 +52,10 @@ if [[ ! -f "$CONFIG_DIR/server.crt" || ! -f "$CONFIG_DIR/server.key" ]]; then
51
52
  fi
52
53
 
53
54
  sudo usermod -aG dialout,cdrom "$USER" || true
55
+ # Don't let the kernel re-close an ejected tray whenever some process (a desktop
56
+ # volume monitor, a disc probe) opens the drive.
57
+ echo 'dev.cdrom.autoclose = 0' | sudo tee /etc/sysctl.d/90-discstation.conf >/dev/null \
58
+ && sudo sysctl -q -w dev.cdrom.autoclose=0 || true
54
59
  mkdir -p "$HOME/.config/systemd/user"
55
60
  cp "$ROOT_DIR/systemd/discstation.service" "$HOME/.config/systemd/user/discstation.service"
56
61
  systemctl --user daemon-reload
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discstation",
3
- "version": "0.1.35",
3
+ "version": "0.1.38",
4
4
  "description": "DiscStation optical-media appliance host — one cross-OS installer",
5
5
  "bin": {
6
6
  "discstation-setup": "scripts/setup.mjs",
@@ -13,10 +13,8 @@ python-libdiscid>=2.0 ; sys_platform == "linux"
13
13
  musicbrainzngs>=0.7.1
14
14
  tmdbsimple>=2.9
15
15
 
16
- # OLED spectrum visualizer during playback. Linux needs `parec`/`pactl`
17
- # (pulseaudio-utils apt package) - prefer python3-numpy via apt there, pip
18
- # installs are blocked on a system Python. macOS needs the BlackHole
19
- # loopback driver (brew install blackhole-2ch, see docs/PLATFORM_SUPPORT.md
20
- # for the one-time Multi-Output Device setup); numpy installs fine via pip
21
- # there since install-macos.sh's venv isn't externally-managed.
22
- numpy>=1.24 ; sys_platform == "linux" or sys_platform == "darwin"
16
+ # OLED spectrum visualizer during playback (Linux only). Needs `parec`/`pactl`
17
+ # (pulseaudio-utils apt package) - prefer python3-numpy via apt, pip installs
18
+ # are blocked on a system Python. macOS can't capture system audio from a CLI
19
+ # tool - see docs/PLATFORM_SUPPORT.md.
20
+ numpy>=1.24 ; sys_platform == "linux"
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env bash
2
+ # Static sanity checks for every language in the repo. Run before committing.
3
+ set -euo pipefail
4
+ cd "$(dirname "$0")/.."
5
+
6
+ python3 -m py_compile src/*.py
7
+ for f in install.sh install-macos.sh scripts/*.sh; do bash -n "$f"; done
8
+ for f in scripts/*.mjs scripts/lib/*.mjs src/static/app.js; do node --check "$f"; done
9
+ (cd mobile && ./node_modules/.bin/tsc --noEmit --noUnusedLocals)
10
+
11
+ # Import the host and exercise the pure status/menu logic with canned input.
12
+ (cd src && python3 - <<'EOF'
13
+ import discstation as d
14
+ d._record_web_status("PLAY_MODE:AUDIO_CD")
15
+ assert d._status_snapshot()["playing"] is True
16
+ d._record_web_status("PROGRESS:42%")
17
+ assert d._status_snapshot()["progress"] == 42
18
+ d._record_web_status("DONE:ok")
19
+ assert d._status_snapshot()["playing"] is False
20
+
21
+ # Burn-flow helpers: history recording for every exit path, START/SPEED/MODE/CANCEL.
22
+ import json, pathlib, tempfile
23
+ d.HISTORY_FILE = pathlib.Path(tempfile.mkdtemp()) / "h.jsonl"
24
+ with d._burn_history({"title": "t"}):
25
+ pass
26
+ try:
27
+ with d._burn_history({"title": "t"}):
28
+ raise RuntimeError("boom")
29
+ except RuntimeError:
30
+ pass
31
+ with d._burn_history({"title": "t"}, swallow_cancel=True) as h:
32
+ raise d.CancelError("x")
33
+ assert h.cancelled
34
+ rows = [json.loads(l) for l in open(d.HISTORY_FILE)]
35
+ assert [r["success"] for r in rows] == [True, False, False] and rows[1]["error"] == "boom"
36
+ ser = d.VirtualSerial()
37
+ for line in ("SPEED:6x", "START"):
38
+ ser.push_line(line)
39
+ assert d._wait_for_start(ser, "audio burn") == (None, "6x")
40
+ for line in ("MODE:BEST", "START"):
41
+ ser.push_line(line)
42
+ assert d._wait_for_start(ser, mode="AUTO") == ("BEST", None)
43
+ ser.push_line("CANCEL")
44
+ assert d._wait_for_start(ser, "x") is None
45
+
46
+ # Subprocess-output iterators (shared reader thread) + cancel handling.
47
+ import subprocess, sys
48
+ def spawn(code):
49
+ return subprocess.Popen([sys.executable, "-c", code], stdout=subprocess.PIPE, text=True)
50
+ assert list(d.discstation_burn.iter_proc_or_cancel(spawn("print('a');print('b')"), ser)) == ["a", "b"]
51
+ assert [e for e in d.iter_process_events(spawn("print('x')"), idle_seconds=0.2, ser=ser) if e] == ["x"]
52
+ ser.push_line("CANCEL")
53
+ try:
54
+ list(d.iter_process_events(spawn("import time;time.sleep(30)"), idle_seconds=0.2, ser=ser))
55
+ raise SystemExit("cancel not raised")
56
+ except d.CancelError:
57
+ pass
58
+ print("smoke ok")
59
+ EOF
60
+ )
61
+ echo "all checks passed"