diff --git a/CLAUDE.md b/CLAUDE.md index 731fbb1..6b5124d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,11 +121,12 @@ main.py - **TTS pre-rendering** — all fixed `SCRIPT_*` strings from `SCRIPTS.md` are pre-rendered via `prerender({script_name: text})` at startup; only dynamic strings use live Piper at runtime - **Piper invocation** — `_run_piper(text, output_path)` uses `--output_file ` so Piper writes a valid RIFF/WAV file directly; never use `--output-raw` (raw PCM) as it produces files that `wave.open()` cannot read - **Live TTS temp files** — `_synthesize()` writes to `/live/` (not system `/tmp`); `PiperTTS.__init__` wipes this directory on startup to clear session orphans; each live file is deleted immediately after `audio.play_file()` returns (safe because `SounddeviceAudio.play_file` reads the file eagerly before enqueuing); `speak()` returns a path the caller owns — it is not auto-deleted by `PiperTTS` -- **GPIO cleanup on exit** — `run()` sets `_gpio_ready = True` after `build_gpio_handler()` succeeds and calls the module-level `_gpio_cleanup()` in the `finally` block only when `_gpio_ready` is set; `_gpio_cleanup` is a real function (not a lambda) at module scope so tests can patch `src.main._gpio_cleanup` to assert it was called +- **GPIO cleanup on exit** — `run()` sets `_gpio_ready = True` after `gpio_setup()` succeeds and calls the module-level `_gpio_cleanup()` in the `finally` block only when `_gpio_ready` is set; `_gpio_cleanup` is a real function (not a lambda) at module scope so tests can patch `src.main._gpio_cleanup` to assert it was called - **Hang-up never stops media playback** — `HANDSET_ON_CRADLE` stops local audio only; music keeps playing on the backend - **Never hang up on the user** — the system must always be doing something while the handset is lifted; the only exit is the off-hook warning tone for unrecoverable dead-ends or inactivity timeout (`INACTIVITY_TIMEOUT = 30s`) -- **Digit disambiguation** — first digit waits `DIRECT_DIAL_DISAMBIGUATION_TIMEOUT` for a second; single digit = navigation (`0`=top, `9`=back, `1`–`8`=option); two digits within timeout = enter `DIRECT_DIAL` mode where `0`/`9` are literal -- **Digit before menu guard** — if a digit's disambiguation timeout fires while state is still `IDLE_DIAL_TONE`, `_dispatch_navigation_digit` stops the dial tone, delivers the appropriate menu (idle or playing), then **drops the digit**; the user must dial again after hearing the options; this prevents `SCRIPT_NOT_IN_SERVICE` from being spoken for premature digits +- **Two dialing paths from `IDLE_DIAL_TONE`** — digit `0` immediately delivers the operator menu (idle, playing, or radio); any non-zero digit (`1`–`9`) immediately enters `DIRECT_DIAL` mode to collect a 7-digit number; no disambiguation wait; phone numbers never start with `0` +- **`DIAL_ENTRY_TIMEOUT`** — `IDLE_DIAL_TONE` and `DIRECT_DIAL` share a single 60-second window from handset lift; if 7 digits are not completed within that window, the system enters `OFF_HOOK` mode; `INACTIVITY_TIMEOUT` (30 s) applies separately to all operator-menu states +- **`DIRECT_DIAL` only from `IDLE_DIAL_TONE`** — once the user is inside the operator menu (browse, playing, etc.), dialing digits navigates the menu; there is no direct-dial re-entry from within a menu - **DTMF feedback** — `play_dtmf(digit)` called for each digit in `DIRECT_DIAL` mode - **No local playback state** — all pause/play state derived from `now_playing()` → `PlaybackState` at speak time; never tracked locally - **`SCRIPT_OPERATOR_OPENER` spoken once per session** — only on the first menu prompt after handset lift; subsequent prompts omit it @@ -142,9 +143,9 @@ main.py - **Genre playback flow** — selecting a genre calls `get_tracks_for_genre(genre_media_key)` then `play_tracks(track_keys, shuffle=True)`; if the genre has no tracks, `SCRIPT_NOT_IN_SERVICE` is spoken and state returns to `BROWSE_GENRES`; `play()` is never called for genres - **Radio media_key encoding** — radio entries in the phone book use `media_key = "radio:{frequency_hz}"` (e.g. `"radio:90300000.0"`) and `media_type = "radio"`; seeded at startup via `phone_book.seed()` from `radio_stations.json`; `menu._execute_direct_dial()` parses the frequency from the media_key and calls `radio.play(frequency_hz)` - **Radio playback flow** — dialing a radio number stops any active media playback, stops any active radio stream, speaks `SCRIPT_RADIO_CONNECTING`, calls `radio.play(frequency_hz)`, and transitions to `RADIO_PLAYING_MENU`; hang-up never stops radio -- **Radio playing menu** — `RADIO_PLAYING_MENU` state offers only disconnect (digit 3 → `radio.stop()` → `IDLE_MENU`) and new party (digit 0 → `radio.stop()` → `IDLE_MENU`); no pause, no skip; lifting the handset while radio is playing delivers `SCRIPT_RADIO_PLAYING_GREETING` then `SCRIPT_RADIO_PLAYING_MENU` +- **Radio playing menu** — `RADIO_PLAYING_MENU` state offers only disconnect (digit 3 → `radio.stop()` → `IDLE_MENU`) and new party (digit 0 → `radio.stop()` → `IDLE_MENU`); no pause, no skip; dialing 0 from `IDLE_DIAL_TONE` while radio is active delivers `SCRIPT_RADIO_PLAYING_GREETING` then `SCRIPT_RADIO_PLAYING_MENU` - **Radio state is local** — unlike MPD state (never tracked locally; always queried via `now_playing()`), radio playing state is tracked via `radio.is_playing()`; there is no remote authority to query; menu checks `radio.is_playing()` when `media_client.now_playing().item is None` to decide between idle and radio playing menus -- **Failed direct dial re-delivers prior menu** — `_pre_dial_state` is set in `_enter_direct_dial()` to the state before DIRECT_DIAL was entered; on failure (`entry is None`), if `_pre_dial_state` was `IDLE_DIAL_TONE` (user dialed before any menu was delivered), `now_playing()` is queried to pick the correct top-level menu; otherwise `_state` is restored to `_pre_dial_state` and `_re_deliver_current_state()` re-announces the menu; `_pre_dial_state` is cleared in `on_handset_on_cradle()` +- **Failed direct dial delivers top-level menu** — on failure (`entry is None`), `now_playing()` is queried to pick the correct top-level menu (idle, playing, or radio); `SCRIPT_NOT_IN_SERVICE` is spoken first - **`load_radio_stations(path)`** — module-level helper in `main.py`; reads `radio_stations.json`, converts `frequency_mhz` → `frequency_hz` (multiply by 1_000_000), returns `list[RadioStation]`; returns `[]` (with warning log) on `FileNotFoundError` or JSON/key parse error; never raises ### Data stores @@ -154,8 +155,7 @@ main.py ### Configuration constants (from `DESIGN.md`) | Constant | Value | |---|---| -| `DIAL_TONE_TIMEOUT_IDLE` | 5 s | -| `DIAL_TONE_TIMEOUT_PLAYING` | 2 s | +| `DIAL_ENTRY_TIMEOUT` | 60 s — window from handset lift to complete a 7-digit number or dial 0 for operator; applies to `IDLE_DIAL_TONE` and `DIRECT_DIAL` | | `INTER_DIGIT_TIMEOUT` | 300 ms | | `DIAL_TONE_FREQUENCIES` | [350, 440] Hz | | `MAX_MENU_OPTIONS` | 8 | diff --git a/INSTALL.md b/INSTALL.md index 186bce2..f3f2711 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -6,7 +6,7 @@ - A supported media player: **MPD (Music Player Daemon)** or **Mopidy** running and accessible on the network - Hardware wired up per the setup guides in `docs/`: - `docs/AMP_SETUP.md` — MAX98357 I2S amplifier - - `docs/BREAKBEAM_SETUP.md` — IR breakbeam pulse switch + - `docs/PULSE_SWITCH_SETUP.md` — pulse switch - `docs/HOOK_SWITCH_SETUP.md` — hook switch There are two installation paths. Both end up at the same [Configure](#step-configure) step. @@ -211,7 +211,7 @@ sudo systemctl restart hello-operator-web | Symptom | Where to look | |---|---| | No audio from handset | `docs/AMP_SETUP.md` | -| Dial pulses not detected | `docs/BREAKBEAM_SETUP.md` | +| Dial pulses not detected | `docs/PULSE_SWITCH_SETUP.md` | | Handset lift not detected | `docs/HOOK_SWITCH_SETUP.md` | | Service fails to start | `sudo journalctl -u hello-operator -n 50` | | Web interface unreachable | Check `sudo systemctl status hello-operator-web`; confirm port 8080 is not blocked | diff --git a/README.md b/README.md index 5abf8be..7521109 100644 --- a/README.md +++ b/README.md @@ -28,13 +28,13 @@ All menus are spoken aloud. There are no screens. | Vintage rotary phone | Handset, speaker, dial | | Adafruit MAX98357A I2S amplifier | Drives the handset speaker | | Hook switch → GPIO | Detects handset up/down | -| Rotary dial → IR breakbeam → GPIO | Decodes dialed digits | +| Rotary dial → GPIO | Decodes dialed digits | | RTL-SDR USB dongle (optional) | FM radio via `rtl_fm` | Setup guides for each hardware component are in `docs/`: - [`docs/AMP_SETUP.md`](docs/AMP_SETUP.md) — MAX98357A I2S amplifier -- [`docs/BREAKBEAM_SETUP.md`](docs/BREAKBEAM_SETUP.md) — IR breakbeam pulse switch +- [`docs/PULSE_SWITCH_SETUP.md`](docs/PULSE_SWITCH_SETUP.md) — pulse switch - [`docs/HOOK_SWITCH_SETUP.md`](docs/HOOK_SWITCH_SETUP.md) — hook switch - [`docs/PIPER_SETUP.md`](docs/PIPER_SETUP.md) — Piper TTS voice engine diff --git a/docs/BREAKBEAM_SETUP.md b/docs/BREAKBEAM_SETUP.md deleted file mode 100644 index 8834344..0000000 --- a/docs/BREAKBEAM_SETUP.md +++ /dev/null @@ -1,236 +0,0 @@ -# Rotary Pulse Switch Setup - -A rotary dial generates digits by opening and closing a pulse switch as the dial returns to home position. The number of openings encodes the digit (1 = 1, 10 = 0). hello-operator reads a HIGH GPIO level as "resting" and LOW as "pulsing." - -Two wiring options are described below. Choose based on whether you want galvanic isolation from the phone's internal wiring. - -| | Option A — IR Breakbeam | Option B — Direct wire | -|---|---|---| -| Parts | Breakbeam sensor pair + 150Ω resistor | 470Ω–1kΩ resistor | -| Isolation | Yes — optical barrier between switch and GPIO | No — switch contacts connect directly to Pi | -| Prerequisite | None | Switch must be physically disconnected from the phone's 48V loop circuit | - ---- - -## Option A — IR Breakbeam sensor - -### How it works - -The pulse switch is wired **in series with the IR emitter LED**. When the switch is closed (dial at rest), the LED is powered and the receiver sees the beam. When the switch opens on each pulse, the LED goes dark — simulating a blocked beam without anything needing to pass between the sensors. - -``` -Pi 3.3V ── R (150Ω) ── IR emitter LED ── pulse switch ── GND - -receiver signal ── GPIO 27 (pull-up) -``` - -| Switch state | LED | Receiver output | GPIO | Meaning | -|---|---|---|---|---| -| Closed (resting) | ON | HIGH | 1 | Dial at rest | -| Open (pulsing) | OFF | LOW | 0 | Pulse | - -The optical link is the isolation barrier — the same principle as an optocoupler. There is no electrical path from the pulse switch to the GPIO pin. Voltage spikes or contact bounce on the switch side do not reach the Pi. - -### Parts needed - -- Adafruit IR Breakbeam sensor pair (3mm or 5mm LED variant) -- 150Ω resistor (for 3.3V supply) — see note below -- 6 jumper wires (female-to-female) -- The rotary dial's pulse switch contacts (wired to the emitter circuit, not GPIO) - -> **Resistor value:** The current-limiting resistor goes in series with the emitter LED, not the receiver. For a 3.3V supply and a typical IR LED forward voltage of ~1.2V at 20mA: R = (3.3 − 1.2) / 0.020 ≈ 105Ω — use a 150Ω resistor (slightly conservative, fine for close-range use). If you power the emitter from 5V instead, use a 180–220Ω resistor. - -### Pinout - -**Emitter (2 wires + pulse switch)** - -| Wire colour | Function | -|---|---| -| Red | Power — connects to 3.3V via the 150Ω resistor | -| Black | Ground — connects via the pulse switch contacts | - -The pulse switch is wired in the ground leg of the emitter circuit: one switch contact to the emitter's black wire, the other contact to GND on the Pi. Either leg (power or ground) works electrically; the ground leg is slightly simpler to wire. - -**Receiver (3 wires)** - -| Wire colour | Function | -|---|---| -| Red | Power — 3.3V | -| Black | Ground | -| White / Yellow | Signal output (open-collector — reads HIGH at rest, LOW when beam is off) | - -> **Note:** The receiver output is open-collector. A pull-up resistor is required. hello-operator enables the Raspberry Pi's internal pull-up in software (`GPIO.PUD_UP`), so **no external resistor is needed** on the receiver signal wire. - -### Wiring to the Raspberry Pi - -**Emitter circuit** - -| Connection | From | To | -|---|---|---| -| 3.3V → 150Ω resistor → emitter red | Pi pin 1 (3.3V) | Emitter red wire (via resistor) | -| Emitter black → pulse switch → GND | Emitter black wire | One pulse switch contact; other contact to Pi GND (pin 6, 9, 14, 20, 25, 30, 34, or 39) | - -**Receiver circuit** - -| Sensor pin | Raspberry Pi | Physical pin | -|---|---|---| -| Red (power) | 3.3V | Pin 1 or 17 | -| Black (GND) | Ground | Any ground pin above | -| White / Yellow (signal) | GPIO 27 | Pin 13 | - -> **5V receiver output warning:** Some breakbeam modules designed for 5V operation output a 5V logic signal. The Pi's GPIO pins are 3.3V-tolerant only — a 5V signal will damage them. Power the receiver from the Pi's 3.3V rail to keep the output signal at 3.3V. If your sensor requires 5V to operate, add a voltage divider (e.g. 10kΩ / 20kΩ) or a level shifter on the receiver signal wire before it reaches GPIO. - -### Physical mounting - -The emitter and receiver still need to face each other, but no gap or moving mechanism is required between them. They can be mounted anywhere convenient because the switching happens electrically, not physically. - -1. Mount the emitter and receiver so they have line of sight to each other. -2. Wire the emitter into the pulse switch circuit as described above. -3. Connect the receiver signal wire to GPIO 27. -4. Do **not** remove or bypass the pulse switch contacts — the switch is the active part of the circuit. - -Secure both sensors to prevent them from shifting; the emitter and receiver still need to maintain alignment with each other. - ---- - -## Option B — Direct wire - -If the pulse switch contacts have been physically disconnected from the phone's 48V loop circuit, they are just a bare mechanical switch and can connect directly to the Pi with a single series resistor. - -### Prerequisite: isolate the switch from 48V - -Vintage telephone internals run 48VDC loop current. The pulse switch contacts are normally in series with this circuit. **Before wiring to the Pi, confirm the switch contacts carry no line voltage:** - -1. Disconnect the phone from the telephone line jack. -2. Physically disconnect (desolder or unclip) the pulse switch wires from the telephone's main circuit board or line terminals. -3. With a multimeter set to DC voltage, measure across the two switch contacts. The reading should be ~0V. If it is not, the switch is still connected to a live circuit — do not proceed. - -Once the contacts are isolated, they are a safe low-voltage switch with no path back to 48V. - -### How it works - -The rotary pulse switch is normally closed at rest and opens briefly on each pulse. The circuit connects one contact to the Pi's 3.3V rail through a series resistor; the other contact connects to the GPIO pin. An internal pull-down holds the GPIO LOW when the switch opens. - -``` -Pi 3.3V ── [470Ω–1kΩ] ── (pulse switch) ── GPIO 27 (pull-down) - │ - ~50kΩ internal pull-down - │ - GND -``` - -| Switch state | GPIO | Meaning | -|---|---|---| -| Closed (resting) | HIGH (~3.3V) | Dial at rest | -| Open (pulsing) | LOW (pulled to GND) | Pulse | - -### Series resistor - -A series resistor between 470Ω and 1kΩ is required. It: -- Limits current if the GPIO pin is ever misconfigured as a logic output (without it, 3.3V would short directly to the pin) -- Provides a small amount of RC filtering against contact bounce - -The resistor has no meaningful effect on signal integrity at low frequencies. Current through the resistor when the switch is closed: 3.3V ÷ (1kΩ + 50kΩ internal) ≈ 64μA — well within the Pi's GPIO ratings. The GPIO reads a clean HIGH (~3.27V) with a 1kΩ resistor in this divider. - -If you want a more reliable pull-down than the Pi's internal ~50kΩ (useful if the switch leads are long), add an external 10kΩ resistor from GPIO 27 to GND alongside the internal pull-down. An external pull-down also lets you leave the GPIO configured as `GPIO.PUD_OFF` if preferred. - -### Wiring to the Raspberry Pi - -| Connection | From | To | -|---|---|---| -| 3.3V → series resistor → switch contact A | Pi pin 1 (3.3V) | Switch contact A (via 470Ω–1kΩ resistor) | -| Switch contact B → GPIO 27 | Switch contact B | Pin 13 (GPIO 27) | - -Polarity of the switch contacts does not matter — it is a simple mechanical switch. - -> **GPIO pin:** The default pulse switch pin is GPIO 27 (`PULSE_SWITCH_PIN = 27` in `src/constants.py`). If you wire to a different pin, update that constant. - -> **Tip:** Use [pinout.xyz](https://pinout.xyz) to locate physical pin positions on your Pi revision. - -### Required code change in `main.py` - -The breakbeam receiver (Option A) uses an open-collector output that requires a **pull-up**. The direct connection (Option B) requires a **pull-down**. These are incompatible, so one line in `src/main.py` must be changed before running hello-operator with a direct-wired switch. - -Find the `build_gpio_handler()` function and change the pulse pin setup from `GPIO.PUD_UP` to `GPIO.PUD_DOWN`: - -```python -# Option A (breakbeam) — default -GPIO.setup(PULSE_SWITCH_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) - -# Option B (direct wire) — change to this -GPIO.setup(PULSE_SWITCH_PIN, GPIO.IN, pull_up_down=GPIO.PUD_DOWN) -``` - -Only the pulse pin needs to change. The hook switch (`HOOK_SWITCH_PIN`) keeps `GPIO.PUD_UP` regardless of which option you use for the pulse switch. - ---- - -## Verify signal before connecting to the Pi - -For either option, confirm the circuit is working correctly before relying on hello-operator: - -**Option A (breakbeam):** -1. Power the emitter circuit (3.3V → resistor → LED → switch → GND) and the receiver from the Pi. -2. Add a temporary 10kΩ pull-up from the receiver signal wire to 3.3V. -3. Measure voltage on the receiver signal wire: switch closed → ~3.3V; switch open → ~0V. -4. Remove the temporary resistor before connecting to GPIO. - -**Option B (direct):** -1. With the Pi powered off, measure resistance across the switch contacts: closed = ~0Ω, open = ∞. -2. Power the Pi. Measure voltage at the GPIO pin (with pull-down enabled in software): switch closed → ~3.3V; switch open → ~0V. - ---- - -## Smoke test with hello-operator - -With the sensor wired to GPIO 27 and the Pi booted: - -```python -import RPi.GPIO as GPIO -import time - -PIN = 27 -GPIO.setmode(GPIO.BCM) - -# Option A (breakbeam): -GPIO.setup(PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) - -# Option B (direct wire) — use this line instead: -# GPIO.setup(PIN, GPIO.IN, pull_up_down=GPIO.PUD_DOWN) - -print("Watching GPIO 27 — open the pulse switch to test. Ctrl+C to stop.") -last = GPIO.input(PIN) -while True: - val = GPIO.input(PIN) - if val != last: - print("PULSE (switch open)" if val == 0 else "resting (switch closed)") - last = val - time.sleep(0.001) -``` - -Run this script and manually open and close the pulse switch contacts (or dial a number). You should see `PULSE (switch open)` on each opening and `resting (switch closed)` when closed. Dial the digit 1 and confirm exactly one pulse is reported. - ---- - -## Troubleshooting - -**Signal always LOW (reads as constant pulsing)** -- *Option A:* The LED is not lighting up. Check emitter power, the resistor, and that the pulse switch contacts are currently closed. If the contacts are open at rest the LED will never light. -- *Option B:* The GPIO is floating. Confirm the pull-down is active (check `GPIO.PUD_DOWN` in the smoke test). Check that the switch contact B wire is connected to GPIO 27, not left floating. - -**Signal always HIGH (no pulses detected)** -- *Option A:* The receiver never sees a dark period. Check that the pulse switch contacts actually open when the dial moves. Confirm emitter and receiver are aligned. -- *Option B:* The switch contacts may not be opening, or the 3.3V side is disconnected. With the switch open, the GPIO should float LOW via the pull-down. - -**Erratic or noisy signal** -Contact bounce on the pulse switch may cause rapid glitching around each transition. This is expected for mechanical contacts and is handled by `PULSE_DEBOUNCE` in `src/constants.py`. Tune that constant if pulses are being over- or under-counted. - -**Signal voltage reads ~1.5V instead of 0V or 3.3V** -- *Option A:* The receiver output needs a pull-up. Confirm `GPIO.PUD_UP` is set. If testing outside hello-operator, add a 10kΩ resistor from the signal wire to 3.3V. -- *Option B:* Weak or missing pull-down. Confirm `GPIO.PUD_DOWN` is set, or add an external 10kΩ to GND. - -**Ambient light interference (Option A only)** -The sensors are sensitive to strong IR sources including direct sunlight. Shield the receiver from direct ambient light or orient it so the only IR source it sees is the emitter. - -**Switch contacts still read non-zero voltage after isolation (Option B)** -The switch has not been fully isolated from the phone's 48V circuit. Trace the wires and confirm both contacts are disconnected from all telephone circuitry before connecting to the Pi. diff --git a/docs/PULSE_SWITCH_SETUP.md b/docs/PULSE_SWITCH_SETUP.md new file mode 100644 index 0000000..b955981 --- /dev/null +++ b/docs/PULSE_SWITCH_SETUP.md @@ -0,0 +1,108 @@ +# Rotary Pulse Switch Setup + +A rotary dial generates digits by opening and closing a pulse switch as the dial returns to home position. The number of openings encodes the digit (1 = 1, 10 = 0). hello-operator reads a HIGH GPIO level as "resting" and LOW as "pulsing." + +If the pulse switch contacts have been physically disconnected from the phone's 48V loop circuit, they are just a bare mechanical switch and can connect directly to the Pi with a single series resistor. + +## Prerequisite: isolate the switch from 48V + +Vintage telephone internals run 48VDC loop current. The pulse switch contacts are normally in series with this circuit. **Before wiring to the Pi, confirm the switch contacts carry no line voltage:** + +1. Disconnect the phone from the telephone line jack. +2. Physically disconnect (desolder or unclip) the pulse switch wires from the telephone's main circuit board or line terminals. +3. With a multimeter set to DC voltage, measure across the two switch contacts. The reading should be ~0V. If it is not, the switch is still connected to a live circuit — do not proceed. + +Once the contacts are isolated, they are a safe low-voltage switch with no path back to 48V. + +## How it works + +The rotary pulse switch is normally closed at rest and opens briefly on each pulse. The circuit connects one contact to the Pi's 3.3V rail through a series resistor; the other contact connects to the GPIO pin. An internal pull-down holds the GPIO LOW when the switch opens. + +``` +Pi 3.3V ── [470Ω–1kΩ] ── (pulse switch) ── GPIO 27 (pull-down) + │ + ~50kΩ internal pull-down + │ + GND +``` + +| Switch state | GPIO | Meaning | +|---|---|---| +| Closed (resting) | HIGH (~3.3V) | Dial at rest | +| Open (pulsing) | LOW (pulled to GND) | Pulse | + +## Series resistor + +A series resistor between 470Ω and 1kΩ is required. It: +- Limits current if the GPIO pin is ever misconfigured as a logic output (without it, 3.3V would short directly to the pin) +- Provides a small amount of RC filtering against contact bounce + +The resistor has no meaningful effect on signal integrity at low frequencies. Current through the resistor when the switch is closed: 3.3V ÷ (1kΩ + 50kΩ internal) ≈ 64μA — well within the Pi's GPIO ratings. The GPIO reads a clean HIGH (~3.27V) with a 1kΩ resistor in this divider. + +If you want a more reliable pull-down than the Pi's internal ~50kΩ (useful if the switch leads are long), add an external 10kΩ resistor from GPIO 27 to GND alongside the internal pull-down. An external pull-down also lets you leave the GPIO configured as `GPIO.PUD_OFF` if preferred. + +## Wiring to the Raspberry Pi + +| Connection | From | To | +|---|---|---| +| 3.3V → series resistor → switch contact A | Pi pin 1 (3.3V) | Switch contact A (via 470Ω–1kΩ resistor) | +| Switch contact B → GPIO 27 | Switch contact B | Pin 13 (GPIO 27) | + +Polarity of the switch contacts does not matter — it is a simple mechanical switch. + +> **GPIO pin:** The default pulse switch pin is GPIO 27 (`PULSE_SWITCH_PIN = 27` in `src/constants.py`). If you wire to a different pin, update that constant. + +> **Tip:** Use [pinout.xyz](https://pinout.xyz) to locate physical pin positions on your Pi revision. + +## Verify signal before connecting to the Pi + +Confirm the circuit is working correctly before relying on hello-operator: + +1. With the Pi powered off, measure resistance across the switch contacts: closed = ~0Ω, open = ∞. +1. Power the Pi. Measure voltage at the GPIO pin (with pull-down enabled in software): switch closed → ~3.3V; switch open → ~0V. + +--- + +## Smoke test with hello-operator + +With the sensor wired to GPIO 27 and the Pi booted: + +```python +import RPi.GPIO as GPIO +import time + +PIN = 27 +GPIO.setmode(GPIO.BCM) + +GPIO.setup(PIN, GPIO.IN, pull_up_down=GPIO.PUD_DOWN) + +print("Watching GPIO 27 — open the pulse switch to test. Ctrl+C to stop.") +last = GPIO.input(PIN) +while True: + val = GPIO.input(PIN) + if val != last: + print("PULSE (switch open)" if val == 0 else "resting (switch closed)") + last = val + time.sleep(0.001) +``` + +Run this script and manually open and close the pulse switch contacts (or dial a number). You should see `PULSE (switch open)` on each opening and `resting (switch closed)` when closed. Dial the digit 1 and confirm exactly one pulse is reported. + +--- + +## Troubleshooting + +**Signal always LOW (reads as constant pulsing)** +- The GPIO is floating. Confirm the pull-down is active (check `GPIO.PUD_DOWN` in the smoke test). Check that the switch contact B wire is connected to GPIO 27, not left floating. + +**Signal always HIGH (no pulses detected)** +- The switch contacts may not be opening, or the 3.3V side is disconnected. With the switch open, the GPIO should float LOW via the pull-down. + +**Erratic or noisy signal** +Contact bounce on the pulse switch may cause rapid glitching around each transition. This is expected for mechanical contacts and is handled by `PULSE_DEBOUNCE` in `src/constants.py`. Tune that constant if pulses are being over- or under-counted. + +**Signal voltage reads ~1.5V instead of 0V or 3.3V** +- Weak or missing pull-down. Confirm `GPIO.PUD_DOWN` is set, or add an external 10kΩ to GND. + +**Switch contacts still read non-zero voltage after isolation** +The switch has not been fully isolated from the phone's 48V circuit. Trace the wires and confirm both contacts are disconnected from all telephone circuitry before connecting to the Pi. diff --git a/install.sh b/install.sh index 91b9a32..eb40e1b 100644 --- a/install.sh +++ b/install.sh @@ -2,7 +2,7 @@ # install.sh — deploy hello-operator on a Raspberry Pi # # Run from the project directory: -# sudo ./install.sh +# sudo bash install.sh # # Requires: Raspberry Pi OS (64-bit), internet access for apt and Piper download. @@ -47,17 +47,19 @@ echo "" # System packages # --------------------------------------------------------------------------- -# echo "==> Installing system packages..." -# apt-get update -qq -# apt-get install -y \ -# python3 \ -# python3-venv \ -# python3-pip \ -# alsa-utils \ -# rtl-sdr \ -# mpd \ -# mpc \ -# mopidy +echo "==> Installing system packages..." +apt-get update -qq +apt-get install -y \ + python3 \ + python3-venv \ + python3-pip \ + swig \ + build-essential \ + alsa-utils \ + rtl-sdr \ + mpd \ + mpc \ + mopidy # --------------------------------------------------------------------------- # Config directory and files @@ -111,10 +113,11 @@ fi # --------------------------------------------------------------------------- echo "==> Creating Python virtual environment..." -sudo -u "$INSTALL_USER" python3 -m venv "$INSTALL_DIR/venv" +sudo -u "$INSTALL_USER" python3 -m venv --system-site-packages "$INSTALL_DIR/venv" echo "==> Installing Python dependencies..." sudo -u "$INSTALL_USER" "$INSTALL_DIR/venv/bin/pip" install --quiet --upgrade pip wheel +sudo -u "$INSTALL_USER" "$INSTALL_DIR/venv/bin/pip" install --quiet -r "$INSTALL_DIR/requirements-dev.txt" sudo -u "$INSTALL_USER" "$INSTALL_DIR/venv/bin/pip" install --quiet -r "$INSTALL_DIR/requirements-pi.txt" sudo -u "$INSTALL_USER" "$INSTALL_DIR/venv/bin/pip" install --quiet -r "$INSTALL_DIR/requirements-web.txt" diff --git a/requirements-pi.txt b/requirements-pi.txt index 49d9530..8740ef0 100644 --- a/requirements-pi.txt +++ b/requirements-pi.txt @@ -1,3 +1,3 @@ -r requirements.txt -RPi.GPIO +gpiozero piper-tts diff --git a/src/audio.py b/src/audio.py index 12da23f..51076b8 100644 --- a/src/audio.py +++ b/src/audio.py @@ -24,7 +24,7 @@ import time import wave import numpy as np - +from gpiozero import OutputDevice from src.interfaces import AudioInterface log = logging.getLogger(__name__) @@ -33,9 +33,9 @@ # this rate before being written. _SAMPLE_RATE = 44100 -# Number of frames per PCM write to aplay (~20 ms at 44100 Hz). +# Number of frames per PCM write to aplay (~5 ms at 44100 Hz). # Small enough for responsive stop(), large enough to avoid constant syscalls. -_CHUNK_FRAMES = 882 # 44100 * 0.02 +_CHUNK_FRAMES = 220 # 44100 * 0.005 # Duration of a single DTMF tone (ms). _DTMF_DURATION_MS = 150 @@ -69,6 +69,8 @@ # Off-hook warning tone frequencies (alternating cadence — standard US ROH). _OFF_HOOK_FREQ = [1400, 2060, 2450, 2600] +# Dial tone frequencies +_DIAL_TONE_FREQ = [350, 440] def _generate_tone(frequencies: list, duration_ms: int, sample_rate: int = _SAMPLE_RATE) -> np.ndarray: """Generate a normalised sine wave mix for the given frequencies (float32, mono).""" @@ -80,6 +82,34 @@ def _generate_tone(frequencies: list, duration_ms: int, sample_rate: int = _SAMP wave_data = wave_data / peak return wave_data.astype(np.float32) +class AudioTask: + + def __init__( + self, + description: str, + pcm: bytes, + loop: bool = False + ) -> None: + self._description = description + self._pcm = pcm + self._loop = loop + self._done = False + + def isLoop(self) -> bool: + return self._loop + + def getBytes(self) -> bytes: + return self._pcm + + def describe(self) -> str: + return self._description + + def isDone(self) -> bool: + return self._done + + def stop(self) -> None: + self._done = True + class SounddeviceAudio(AudioInterface): """Concrete audio implementation using a persistent aplay subprocess (raw PCM). @@ -99,48 +129,26 @@ class SounddeviceAudio(AudioInterface): """ - def __init__(self, sample_rate: int = _SAMPLE_RATE, device: str = "default", - volume: float = 1.0, sd_pin: int = None, - _popen=None, _gpio_output=None) -> None: + def __init__( + self, + sd_pin_out: OutputDevice, + sample_rate: int = _SAMPLE_RATE, + device: str = "default", + volume: float = 1.0, + _popen=None + ) -> None: self._sample_rate = sample_rate self._device = device self._volume = max(0.0, min(1.0, volume)) self._popen = _popen if _popen is not None else subprocess.Popen self._lock = threading.Lock() - self._stop_event = threading.Event() self._queue: queue.Queue = queue.Queue() self._busy = False + self._current_task = None # SD pin: drive LOW to cut amp instantly; release to INPUT to re-enable. - # Both callables are None when sd_pin is not configured. - self._amp_off = None - self._amp_on = None - if sd_pin is not None: - if _gpio_output is not None: - self._amp_off = lambda: _gpio_output(sd_pin, False) - self._amp_on = lambda: _gpio_output(sd_pin, None) - else: - try: - import RPi.GPIO as GPIO # type: ignore[import] - _pin = sd_pin - GPIO.setmode(GPIO.BCM) - GPIO.setup(_pin, GPIO.IN) - - def _amp_off(_p=_pin, _g=GPIO): - log.info("SD pin %d → OUTPUT LOW (amp off)", _p) - _g.setup(_p, _g.OUT) - _g.output(_p, _g.LOW) - - def _amp_on(_p=_pin, _g=GPIO): - log.debug("SD pin %d → INPUT (amp on)", _p) - _g.setup(_p, _g.IN) - - self._amp_off = _amp_off - self._amp_on = _amp_on - log.info("SD amp pin configured: BCM %d", sd_pin) - except Exception as exc: - log.warning("SD amp pin setup failed (pin %d): %s", sd_pin, exc) - + self._amp = sd_pin_out + self._proc = None self._worker_thread = threading.Thread(target=self._worker_loop, daemon=True) @@ -154,7 +162,7 @@ def play_tone(self, frequencies: list, duration_ms: int) -> None: """Enqueue a sine wave mix task; returns immediately.""" waveform = _generate_tone(frequencies, duration_ms, self._sample_rate) pcm = self._waveform_to_pcm(waveform) - self._enqueue(lambda: self._write_pcm(pcm)) + self._enqueue(AudioTask("tone(s) " + str(frequencies), pcm)) def play_file(self, path: str) -> None: """Enqueue a WAV file playback task; returns immediately. @@ -165,23 +173,28 @@ def play_file(self, path: str) -> None: with open(path, 'rb') as f: wav_bytes = f.read() pcm = self._wav_to_pcm(wav_bytes) - self._enqueue(lambda: self._write_pcm(pcm)) + self._enqueue(AudioTask("file " + path, pcm)) def play_dtmf(self, digit: int) -> None: """Enqueue the standard DTMF tone for digit 0–9; returns immediately.""" - freqs = list(_DTMF_FREQ[digit]) - self.play_tone(freqs, _DTMF_DURATION_MS) + self.play_tone(list(_DTMF_FREQ[digit]), _DTMF_DURATION_MS) + def play_dial_tone(self) -> None: + """Enqueue a looping dial tone task; returns immediately.""" + waveform = _generate_tone(_DIAL_TONE_FREQ, 60000, self._sample_rate) + pcm = self._waveform_to_pcm(waveform) + self._enqueue(AudioTask("dial tone", pcm)) + + def play_off_hook_tone(self) -> None: """Enqueue a looping off-hook warning tone task; returns immediately.""" waveform = _generate_tone(_OFF_HOOK_FREQ, _OFF_HOOK_SEGMENT_MS, self._sample_rate) pcm = self._waveform_to_pcm(waveform) - self._enqueue(lambda: self._write_pcm_loop(pcm)) + self._enqueue(AudioTask("off hook", pcm, True)) def amp_off(self) -> None: """Cut the amp and terminate aplay. Call only from the hook watcher.""" - if self._amp_off: - self._amp_off() + self._amp.off() self.stop() proc, self._proc = self._proc, None if proc is not None: @@ -206,8 +219,7 @@ def amp_on(self) -> None: self._proc.stdin.write(_warmup_silence) except (BrokenPipeError, OSError): pass - if self._amp_on: - self._amp_on() + self._amp.on() def stop(self) -> None: """Stop current playback and clear all queued tasks. @@ -216,12 +228,16 @@ def stop(self) -> None: period, ~20 ms) and drains the queue. The aplay process is left running so the I2S clock stays active. """ - self._stop_event.set() + if (self._current_task is not None): + self._current_task.stop() + + log.debug("stopping with " + str(self._queue.qsize()) + " tasks") while True: try: self._queue.get_nowait() self._queue.task_done() except queue.Empty: + log.debug("queue empty") break with self._lock: self._busy = False @@ -235,26 +251,40 @@ def is_playing(self) -> bool: # Internal helpers # ------------------------------------------------------------------ - def _enqueue(self, task) -> None: + def _enqueue(self, task: AudioTask) -> None: """Put a callable task onto the work queue and clear the stop event.""" - self._stop_event.clear() + # log.info("queueing " + task.describe()) self._queue.put(task) + if (self._current_task is not None): + self._current_task.stop() def _worker_loop(self) -> None: """Daemon worker: run tasks from the queue; write silence when idle.""" chunk_duration = _CHUNK_FRAMES / self._sample_rate - silence = np.zeros(_CHUNK_FRAMES, dtype=np.int16).tobytes() + silence = np.zeros(1, dtype=np.int16).tobytes() while True: try: - task = self._queue.get(timeout=chunk_duration) + self._current_task = self._queue.get(block=False) except queue.Empty: # Keep I2S clock alive between clips. - self._write_raw(silence) + self._enqueue(AudioTask("silence", silence)) + # self._write_raw(silence) continue with self._lock: self._busy = True try: - task() + if(self._current_task.isLoop()): + log.info("Looping " + self._current_task.describe()) + self._write_pcm_loop(self._current_task) + log.debug("done looping " + self._current_task.describe()) + else: + # log.info("Playing " + self._current_task.describe()) + self._write_pcm(self._current_task) + # log.debug("done playing " + self._current_task.describe()) + except (BrokenPipeError, OSError): + pass + except Exception: + log.exception("audio worker: task error") finally: self._queue.task_done() with self._lock: @@ -270,23 +300,32 @@ def _write_raw(self, pcm: bytes) -> None: except (BrokenPipeError, OSError): pass - def _write_pcm(self, pcm: bytes) -> None: + def _write_pcm(self, task: AudioTask) -> None: """Write PCM in chunks, returning early if stop() is called.""" - chunk_bytes = _CHUNK_FRAMES * 2 # int16 = 2 bytes per sample + chunk_bytes = _CHUNK_FRAMES * 2 # int16 = 1 bytes per sample offset = 0 + pcm = task.getBytes() while offset < len(pcm): - if self._stop_event.is_set(): + if task.isDone(): + return + try: + # log.info("audio: writing pcm bytes " + str(pcm[offset:offset + chunk_bytes])) + self._write_raw(pcm[offset:offset + chunk_bytes]) + except (BrokenPipeError, OSError): return - self._write_raw(pcm[offset:offset + chunk_bytes]) offset += chunk_bytes - def _write_pcm_loop(self, pcm: bytes) -> None: + def _write_pcm_loop(self, task: AudioTask) -> None: """Write PCM in a loop until stop() is called (used for off-hook tone).""" chunk_bytes = _CHUNK_FRAMES * 2 offset = 0 - while not self._stop_event.is_set(): + pcm = task.getBytes() + while not task.isDone(): end = offset + chunk_bytes - self._write_raw(pcm[offset:min(end, len(pcm))]) + try: + self._write_raw(pcm[offset:min(end, len(pcm))]) + except (BrokenPipeError, OSError): + return offset = end if offset >= len(pcm): offset = 0 diff --git a/src/constants.py b/src/constants.py index 4174c5d..9bb978f 100644 --- a/src/constants.py +++ b/src/constants.py @@ -39,11 +39,10 @@ # Timing constants (in seconds unless noted) # --------------------------------------------------------------------------- -DIAL_TONE_TIMEOUT_IDLE = 3 # Silence before idle operator prompt -DIAL_TONE_TIMEOUT_PLAYING = 2 # Silence before playing-state prompt INTER_DIGIT_TIMEOUT = 0.3 # Gap after last pulse → digit complete (300 ms) -DIRECT_DIAL_DISAMBIGUATION_TIMEOUT = 1.5 # TODO: tune on hardware — wait after first digit before treating as single nav input -INACTIVITY_TIMEOUT = 30 # Inactivity in any menu state → off-hook warning tone +DIAL_ENTRY_TIMEOUT = 60 # Seconds to dial 0 (operator) or a 7-digit number from IDLE_DIAL_TONE; + # entering DIRECT_DIAL restarts this window; timeout → off-hook tone +INACTIVITY_TIMEOUT = 30 # Inactivity in any operator-menu state → off-hook warning tone # Audio constants DIAL_TONE_FREQUENCIES = [350, 440] # Standard PSTN dial tone (Hz) @@ -66,6 +65,10 @@ raise RuntimeError( f"ASSISTANT_NUMBER must be exactly {PHONE_NUMBER_LENGTH} digits (got {_assistant_number!r})." ) +if _assistant_number[0] == '0': + raise RuntimeError( + "ASSISTANT_NUMBER must not start with 0 (0 is reserved for the operator shortcut)." + ) ASSISTANT_NUMBER: str = _assistant_number # GPIO debounce windows (in seconds) @@ -84,6 +87,7 @@ # GPIO pin assignments (BCM numbering) — optional; defaults match recommended wiring docs HOOK_SWITCH_PIN = int(os.environ.get("HOOK_SWITCH_PIN", "17")) PULSE_SWITCH_PIN = int(os.environ.get("PULSE_SWITCH_PIN", "27")) +ROTARY_SWITCH_PIN = int(os.environ.get("ROTARY_SWITCH_PIN", "26")) _sd_amp_pin_env = os.environ.get("SD_AMP_PIN") SD_AMP_PIN: int | None = int(_sd_amp_pin_env) if _sd_amp_pin_env is not None else None diff --git a/src/dialer.py b/src/dialer.py new file mode 100644 index 0000000..2e35d1d --- /dev/null +++ b/src/dialer.py @@ -0,0 +1,74 @@ +import logging +import threading +import time +from gpiozero import Button, DigitalInputDevice +from typing import Optional +from threading import Lock + +from src.constants import PULSE_DEBOUNCE, INTER_DIGIT_TIMEOUT + +log = logging.getLogger(__name__) + +# Rotary convention: 10 pulses = digit 1 +_PULSE_TO_DIGIT = {i: i for i in range(1, 10)} +_PULSE_TO_DIGIT[10] = 0 + + +class Dialer: + def __init__(self, dial_digit_callback, pulse: Button, rotary: DigitalInputDevice) -> None: + self._pulse = pulse + self._rotary = rotary + self._stop_event = threading.Event() + self._dial_digit_callback = dial_digit_callback + + self._pulse_count: int = 0 + self._pulse_lock = Lock() + self._dialing: bool = False + + def start(self) -> None: + t = threading.Thread(target=self._poll_loop, daemon=True, name="gpio-poll") + t.start() + + def stop(self) -> None: + self._stop_event.set() + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _poll_loop(self) -> None: + log.debug("gpio-poll thread started") + self._rotary.when_activated = self._on_dialing_started + self._rotary.when_deactivated = self._on_dialing_stopped + self._pulse.when_pressed = self._on_switch_opened + while not self._stop_event.is_set(): + try: + log.debug("waiting for a dial") + self._rotary.wait_for_active() + log.debug("processing a dial") + self._rotary.wait_for_inactive() + except Exception: + log.exception("gpio-poll error") + log.debug("gpio-poll thread exiting") + + def _on_dialing_started(self) -> None: + log.debug("dialing started") + self._dialing = True + + def _on_dialing_stopped(self) -> None: + log.debug("dialing stopped") + self._pulse_lock.acquire() + digit = self._pulse_count % 10 + log.debug("digit " + str(digit)) + if(digit is not None): + self._dial_digit_callback(digit) + self._dialing = False + self._pulse_count = 0 + self._pulse_lock.release() + + def _on_switch_opened(self) -> None: + if (self._dialing): + self._pulse_lock.acquire() + self._pulse_count += 1 + log.debug("pulse opened") + self._pulse_lock.release() \ No newline at end of file diff --git a/src/gpio_handler.py b/src/gpio_handler.py deleted file mode 100644 index 2b19e62..0000000 --- a/src/gpio_handler.py +++ /dev/null @@ -1,193 +0,0 @@ -"""GPIO handler for hello-operator. - -Polls GPIO pins and emits clean events to the rest of the system. -Handles debouncing for both the hook switch and the pulse switch. -Decodes pulse bursts into digits using the inter-digit timeout. - -No RPi.GPIO dependency in this module — all pin reads go through injected -callables (hook_pin_reader and pulse_pin_reader), enabling full unit testing -without hardware. - -Debounce model --------------- -Both the hook switch and the pulse switch use the same debounce pattern: - - "commit when the raw value has been continuously stable for at least - DEBOUNCE_WINDOW seconds since it last changed" - -The implementation tracks (candidate_value, candidate_start_time). On each -poll call: - 1. If the raw value differs from the current candidate, update the candidate - and record the current time. - 2. If the raw value matches the current candidate AND (now - candidate_start) - >= debounce_window, the value is considered stable. - -For the hook switch, a stable transition from the committed state fires an event. -For the pulse switch, stable LOW → rising edge starts a pulse; stable HIGH after -LOW → rising edge ends a pulse (validated by its duration). - -Digit decoding (rotary convention) ------------------------------------ -After the last pulse in a burst, if the inter-digit gap elapses with no further -pulses, the burst is decoded: N pulses → digit N (1–9), 10 pulses → digit 0. -""" - -import time -from enum import Enum, auto -from typing import Callable, Optional - -from src.constants import ( - HOOK_DEBOUNCE, - PULSE_DEBOUNCE, - INTER_DIGIT_TIMEOUT, -) - - -class GpioEvent(Enum): - HANDSET_LIFTED = auto() - HANDSET_ON_CRADLE = auto() - DIGIT_DIALED = auto() - - -# Rotary convention: 10 pulses = digit 0 -_PULSE_TO_DIGIT = {i: i for i in range(1, 10)} -_PULSE_TO_DIGIT[10] = 0 - - -class GPIOHandler: - """Polls GPIO and emits decoded events. - - Parameters - ---------- - hook_pin_reader: - Callable returning current hook-switch GPIO level: 0 = lifted, 1 = on cradle. - pulse_pin_reader: - Callable returning current pulse-switch GPIO level: 0 = pulsing, 1 = resting. - """ - - def __init__( - self, - hook_pin_reader: Callable[[], int], - pulse_pin_reader: Callable[[], int], - ) -> None: - self._hook_reader = hook_pin_reader - self._pulse_reader = pulse_pin_reader - - # Hook state machine — debounce - self._hook_state: int = 1 # last committed state (1 = on cradle) - self._hook_candidate: int = 1 # current candidate raw value - self._hook_candidate_time: float = 0.0 # when candidate last changed - - # Pulse state machine — edge detection + burst decoding - self._pulse_last_raw: int = 1 # most recent raw pulse reading - self._pulse_last_change_time: float = 0.0 # when raw last changed - - # Whether we are currently inside a LOW pulse (after stable falling edge) - self._in_pulse: bool = False - self._pulse_start_time: float = 0.0 - - # Burst accumulator - self._pulse_count: int = 0 - self._burst_active: bool = False - self._last_pulse_end_time: float = 0.0 - - def poll(self, now: Optional[float] = None) -> Optional[object]: - """Read GPIO pins once and return an event if one is ready, else None. - - Parameters - ---------- - now: - Fake clock value (seconds). If None, ``time.monotonic()`` is used. - Injected in tests to avoid real sleeps. - - Returns - ------- - GpioEvent.HANDSET_LIFTED, GpioEvent.HANDSET_ON_CRADLE, - (GpioEvent.DIGIT_DIALED, digit: int), or None. - """ - if now is None: - now = time.monotonic() - - # --- Hook switch debounce ------------------------------------------------ - hook_event = self._process_hook(self._hook_reader(), now) - if hook_event is not None: - return hook_event - - # --- Pulse decoder (only when handset is lifted) ------------------------- - if self._hook_state == 0: # handset lifted - return self._process_pulse(self._pulse_reader(), now) - - # Handset on cradle — discard any in-progress burst - if self._burst_active: - self._reset_burst() - - return None - - # ------------------------------------------------------------------ - # Internal helpers - # ------------------------------------------------------------------ - - def _process_hook(self, raw: int, now: float) -> Optional[GpioEvent]: - """Debounce hook switch; emit event when a stable transition is detected.""" - if raw != self._hook_candidate: - # Value changed — restart debounce timer - self._hook_candidate = raw - self._hook_candidate_time = now - - elapsed = now - self._hook_candidate_time - if elapsed >= HOOK_DEBOUNCE - 1e-9 and raw != self._hook_state: - # Candidate has been stable long enough and differs from committed state - self._hook_state = raw - if raw == 0: - return GpioEvent.HANDSET_LIFTED - else: - return GpioEvent.HANDSET_ON_CRADLE - return None - - def _process_pulse(self, raw: int, now: float) -> Optional[object]: - """Detect pulse edges and decode bursts into digits. - - Edge detection uses duration-based validation: - - A falling edge (HIGH→LOW) starts a potential pulse. - - A rising edge (LOW→HIGH) ends the pulse; only counted if the LOW - duration was at least PULSE_DEBOUNCE (filters noise glitches). - - After INTER_DIGIT_TIMEOUT with no new pulses, the burst is decoded. - """ - if raw != self._pulse_last_raw: - prev_raw = self._pulse_last_raw - duration = now - self._pulse_last_change_time - - self._pulse_last_raw = raw - self._pulse_last_change_time = now - - if prev_raw == 1 and raw == 0: - # Falling edge: LOW started — begin potential pulse - self._in_pulse = True - self._pulse_start_time = now - - elif prev_raw == 0 and raw == 1: - # Rising edge: LOW ended - if self._in_pulse and duration >= PULSE_DEBOUNCE: - # Valid pulse: LOW was long enough - self._pulse_count += 1 - self._burst_active = True - self._last_pulse_end_time = now - self._in_pulse = False - - return self._check_inter_digit_timeout(now) - - def _check_inter_digit_timeout(self, now: float) -> Optional[object]: - """Emit DIGIT_DIALED if a burst has finished and the timeout has elapsed.""" - if self._burst_active and not self._in_pulse: - elapsed = now - self._last_pulse_end_time - if elapsed >= INTER_DIGIT_TIMEOUT: - digit = _PULSE_TO_DIGIT.get(self._pulse_count) - self._reset_burst() - if digit is not None: - return (GpioEvent.DIGIT_DIALED, digit) - return None - - def _reset_burst(self): - self._pulse_count = 0 - self._burst_active = False - self._in_pulse = False diff --git a/src/interfaces.py b/src/interfaces.py index dcba799..c7a4e0c 100644 --- a/src/interfaces.py +++ b/src/interfaces.py @@ -53,6 +53,10 @@ def play_dtmf(self, digit: int) -> None: def play_off_hook_tone(self) -> None: """Play off-hook warning tone continuously until stop().""" + @abstractmethod + def play_dial_tone(self) -> None: + """Play dial tone continuously until stop().""" + @abstractmethod def stop(self) -> None: """Stop any current playback immediately.""" diff --git a/src/main.py b/src/main.py index f93b09a..06d16bd 100644 --- a/src/main.py +++ b/src/main.py @@ -13,22 +13,21 @@ MEDIA_BACKEND, MPD_HOST, MPD_PORT, PIPER_BINARY, PIPER_MODEL, TTS_CACHE_DIR, - HOOK_SWITCH_PIN, PULSE_SWITCH_PIN, SD_AMP_PIN, - HOOK_DEBOUNCE, + HOOK_SWITCH_PIN, PULSE_SWITCH_PIN, ROTARY_SWITCH_PIN, SD_AMP_PIN, RADIO_CONFIG_PATH, ALSA_DEVICE, AUDIO_VOLUME, ) +from gpiozero import Button, OutputDevice, DigitalInputDevice from src.error_queue import SqliteErrorQueue +from src.phone import Phone from src.phone_book import PhoneBook from src.audio import SounddeviceAudio from src.tts import PiperTTS from src.mpd_client import MPDClient from src.media_store import MediaStore from src.radio import RtlFmRadio -from src.gpio_handler import GPIOHandler, GpioEvent from src.interfaces import RadioStation, MediaClientInterface -from src.session import Session # Import all pre-renderable script strings from menu from src.menu import ( @@ -141,7 +140,6 @@ def load_radio_stations(path: str) -> list: log.warning("Failed to parse radio config at %s: %s — no stations will be seeded", path, exc) return [] - def build_media_client() -> MediaClientInterface: """Construct the configured media client (MPD or Mopidy).""" if MEDIA_BACKEND == "mopidy": @@ -150,79 +148,6 @@ def build_media_client() -> MediaClientInterface: log.info("Media backend: MPD (%s:%d)", MPD_HOST, MPD_PORT) return MPDClient(host=MPD_HOST, port=MPD_PORT) - -def _gpio_cleanup() -> None: - """Call GPIO.cleanup() to release pin reservations on shutdown. - - Imports RPi.GPIO lazily so this module can be imported on non-Pi hosts. - Module-level so tests can patch it; run() only calls it after - build_gpio_handler() has succeeded (i.e. GPIO was actually initialised). - """ - try: - import RPi.GPIO as GPIO # type: ignore[import] - GPIO.cleanup() - except (ImportError, RuntimeError): - pass # Non-Pi environment — nothing to clean up - - -def build_gpio_handler() -> GPIOHandler: - """Construct GPIOHandler with real RPi.GPIO pin readers. - - Raises ImportError if RPi.GPIO is not installed, RuntimeError if not on a Pi. - run() catches both and skips GPIO setup. - """ - import RPi.GPIO as GPIO - GPIO.setmode(GPIO.BCM) - GPIO.setup(HOOK_SWITCH_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) - GPIO.setup(PULSE_SWITCH_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) - - def hook_reader() -> int: - return GPIO.input(HOOK_SWITCH_PIN) - - def pulse_reader() -> int: - return GPIO.input(PULSE_SWITCH_PIN) - - return GPIOHandler( - hook_pin_reader=hook_reader, - pulse_pin_reader=pulse_reader, - ) - -def _start_hook_watcher(hook_pin: int, audio, tts, menu, gpio) -> None: - """Spin a daemon thread that watches the hook pin at ~1 ms intervals. - - Drives the amp and creates/closes Session the instant the pin changes - state, bypassing the polling loop's debounce delay. No-op on non-Pi hosts. - """ - import threading - - def _watch(): - try: - import RPi.GPIO as GPIO - last = GPIO.input(hook_pin) - session = None - while True: - val = GPIO.input(hook_pin) - if val != last: - last = val - if val == 1: # HIGH = on cradle - audio.amp_off() - tts.abort() - if session is not None: - session.close() - session = None - else: # LOW = lifted - audio.amp_on() - session = Session(menu=menu, gpio=gpio) - session.start() - time.sleep(0.001) - except (ImportError, RuntimeError): - pass - - t = threading.Thread(target=_watch, daemon=True, name="hook-watcher") - t.start() - log.info("Hook watcher thread started on BCM %d", hook_pin) - - def run() -> None: """Main entry point — wire all components and start the event loop.""" log.info("hello-operator starting up") @@ -243,9 +168,15 @@ def run() -> None: media_type="radio", name=station.name, ) + + # Set up the GPIO "Buttons" + hook_pin_button = DigitalInputDevice(HOOK_SWITCH_PIN, pull_up=True) + pulse_pin_button = Button(PULSE_SWITCH_PIN, pull_up=True, bounce_time=0.005) + rotary_pin_button = DigitalInputDevice(ROTARY_SWITCH_PIN, pull_up=True, bounce_time=0.1) + shutdown_pin_output = OutputDevice(SD_AMP_PIN) # Hardware interfaces - audio = SounddeviceAudio(device=ALSA_DEVICE, volume=AUDIO_VOLUME, sd_pin=SD_AMP_PIN) + audio = SounddeviceAudio(shutdown_pin_output, device=ALSA_DEVICE, volume=AUDIO_VOLUME) tts = PiperTTS( piper_binary=PIPER_BINARY, piper_model=PIPER_MODEL, @@ -277,14 +208,9 @@ def run() -> None: error_queue=error_queue, radio=radio, ) - - # GPIO handler — track whether GPIO was successfully initialised so the - # finally block only calls _gpio_cleanup() when it is safe to do so. - _gpio_ready = False - gpio = build_gpio_handler() - _gpio_ready = True - _start_hook_watcher(HOOK_SWITCH_PIN, audio, tts, menu, gpio) - + + phone = Phone(hook_pin_button, pulse_pin_button, rotary_pin_button, tts, audio, menu) + phone.start() log.info("hello-operator ready — waiting for handset lift") try: @@ -292,10 +218,11 @@ def run() -> None: time.sleep(1) except KeyboardInterrupt: log.info("Shutting down.") + except Exception: + log.info("Fail!") finally: + phone.stop() audio.stop() - if _gpio_ready: - _gpio_cleanup() if __name__ == "__main__": diff --git a/src/media_store.py b/src/media_store.py index c75db4a..df9530d 100644 --- a/src/media_store.py +++ b/src/media_store.py @@ -14,9 +14,11 @@ import sqlite3 from datetime import datetime, timezone from typing import Dict, List, Optional +import logging from src.interfaces import MediaItem, MediaClientInterface, ErrorQueueInterface +log = logging.getLogger(__name__) def _now_iso() -> str: return datetime.now(timezone.utc).isoformat() @@ -123,6 +125,7 @@ def __init__(self, db_path: str, media_client: MediaClientInterface, self._media_client = media_client self._error_queue = error_queue self._init_db() + self._start_fresh() # ------------------------------------------------------------------ # Schema @@ -143,6 +146,12 @@ def _init_db(self) -> None: ) """) + def _start_fresh(self) -> None: + with self._connect() as conn: + conn.execute(""" + DELETE FROM media_cache + """) + # ------------------------------------------------------------------ # Private DB helpers # ------------------------------------------------------------------ @@ -155,6 +164,7 @@ def _read(self, cache_key: str) -> Optional[List[MediaItem]]: if row is None: return None data = json.loads(row["data"]) + log.info("all the raw data: " + str(data)) return [ MediaItem(media_key=d["media_key"], name=d["name"], media_type=d["media_type"]) for d in data @@ -231,11 +241,13 @@ def get_albums_for_artist(self, artist_media_key: str) -> List[MediaItem]: def _get_or_fetch(self, cache_key: str, fetch_fn) -> List[MediaItem]: """Return local data if available; otherwise fetch, store, and return.""" cached = self._read(cache_key) - if cached is not None: - return cached - items = fetch_fn() - self._write(cache_key, items) - return items + log.info("cached: " + str(cached)) + if cached is None or len(cached) == 0: + items = fetch_fn() + self._write(cache_key, items) + return items + return cached + # ------------------------------------------------------------------ # Item removal (on playback not-found) diff --git a/src/menu.py b/src/menu.py index 228984a..6c4056e 100644 --- a/src/menu.py +++ b/src/menu.py @@ -6,21 +6,31 @@ States ------ -IDLE_DIAL_TONE — handset lifted, playing dial tone, waiting -IDLE_MENU — browsing from idle (no music playing) -PLAYING_MENU — browsing while music is active +IDLE_DIAL_TONE — handset lifted, dial tone playing, waiting for first digit +IDLE_MENU — operator menu from idle (no music playing) +PLAYING_MENU — operator menu while music is active BROWSE_PLAYLISTS / BROWSE_ARTISTS / BROWSE_GENRES / BROWSE_ALBUMS — T9 narrowing ARTIST_SUBMENU — shuffle artist or pick album -DIRECT_DIAL — accumulating digits for a direct phone number +DIRECT_DIAL — accumulating digits for a direct phone number (7 digits) ASSISTANT — diagnostic status readout OFF_HOOK — terminal state; off-hook warning tone playing -Reserved digits (all states except DIRECT_DIAL): +Dialing paths from IDLE_DIAL_TONE +---------------------------------- + 0 → operator menu (IDLE_MENU or PLAYING_MENU); subsequent digits + are navigation within that menu; phone numbers never start with 0 + 1–9 (first) → enter DIRECT_DIAL; collect remaining digits until 7 total; + if 7 digits are not entered within DIAL_ENTRY_TIMEOUT seconds + of the handset lift, off-hook warning fires instead + (no digit) → DIAL_ENTRY_TIMEOUT seconds of silence → off-hook warning + +Navigation digits (in any operator-menu state, dispatched immediately): 0 → go back one level (or stay at top) """ import sqlite3 import time +import logging from enum import Enum, auto from typing import Optional, List @@ -31,9 +41,7 @@ from src.constants import ( ASSISTANT_MESSAGE_PAGE_SIZE, DIAL_TONE_FREQUENCIES, - DIAL_TONE_TIMEOUT_IDLE, - DIAL_TONE_TIMEOUT_PLAYING, - DIRECT_DIAL_DISAMBIGUATION_TIMEOUT, + DIAL_ENTRY_TIMEOUT, INACTIVITY_TIMEOUT, PHONE_NUMBER_LENGTH, MAX_MENU_OPTIONS, @@ -41,6 +49,9 @@ DIGIT_WORDS, ) +log = logging.getLogger("menu") + + # Script text (must match SCRIPTS.md) SCRIPT_OPERATOR_OPENER = "Operator." SCRIPT_GREETING = "How may I direct your call?" @@ -137,6 +148,7 @@ def _strip_article(name: str) -> str: """Strip leading articles for T9 indexing (case-insensitive).""" + log.info("stripping articles from: " + str(name)) lower = name.lower() for article in _ARTICLES: if lower.startswith(article): @@ -164,6 +176,9 @@ def _filter_by_t9_prefix(items: List[MediaItem], prefix: List[int]) -> List[Medi return list(items) result = [] for item in items: + log.info("processing item: " + str(item)) + if not item.name or item.name == "": + continue stripped = _strip_article(item.name) if not stripped: continue @@ -249,13 +264,6 @@ def __init__( self._handset_up_time: float = 0.0 self._last_activity_time: float = 0.0 - # Playback state snapshot at handset lift (avoids polling MPD every tick) - self._lift_playback: Optional[PlaybackState] = None - - # Disambiguation - self._pending_digit: Optional[int] = None - self._pending_digit_time: float = 0.0 - # Direct dial accumulator self._dial_digits: List[int] = [] @@ -271,15 +279,14 @@ def __init__( self._browse_listed: List[MediaItem] = [] # currently listed (≤8) options self._current_artist: Optional[MediaItem] = None # selected artist - # Direct dial: state before entering DIRECT_DIAL (for re-delivery on failure) - self._pre_dial_state: Optional[MenuState] = None - # Assistant sub-state self._assistant_mode: str = "menu" # "menu" | "reading" | "refreshed" self._assistant_messages: List = [] # current message list being read self._assistant_page_offset: int = 0 # how many messages already read self._assistant_digit_map: dict = {} # digit → action - + + log.info("Menu initialized") + # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ @@ -300,44 +307,53 @@ def on_handset_lifted(self, now: Optional[float] = None) -> None: self._state = MenuState.IDLE_DIAL_TONE self._nav_stack.clear() self._dial_digits.clear() - self._pending_digit = None self._failure_mode = None - self._lift_playback = self._media_client.now_playing() - self._audio.play_tone(DIAL_TONE_FREQUENCIES, 2000) + self._audio.play_dial_tone() + log.debug("playing dial tone") def on_handset_on_cradle(self) -> None: """Called when the handset is replaced.""" self._handset_up = False - self._audio.stop() self._tts.abort() self._state = MenuState.IDLE_DIAL_TONE self._nav_stack.clear() self._dial_digits.clear() - self._pending_digit = None - self._pre_dial_state = None self._current_artist = None - def on_digit(self, digit: int, now: Optional[float] = None) -> None: + def on_digit(self, digit: int) -> None: """Called when a digit is decoded.""" + log.info("digit received " + str(digit)) + log.debug("state is " + str(self._state)) if not self._handset_up: return - if now is None: - now = time.monotonic() + now = time.monotonic() self._last_activity_time = now if self._state == MenuState.DIRECT_DIAL: self._handle_direct_dial_digit(digit, now) return - # Disambiguation logic - if self._pending_digit is None: - self._pending_digit = digit - self._pending_digit_time = now - else: - # Second digit within window → enter DIRECT_DIAL - first = self._pending_digit - self._pending_digit = None - self._enter_direct_dial(first, digit, now) + if self._state == MenuState.IDLE_DIAL_TONE: + if digit == 0: + self._audio.play_dtmf(digit) + log.debug("entering operator mode") + # Operator path: deliver the appropriate top-level menu immediately + playback = self._media_client.now_playing() + radio_active = self._radio is not None and self._radio.is_playing() + if playback.item is not None: + self._deliver_playing_menu(playback, now) + elif radio_active: + self._deliver_radio_playing_menu(now) + else: + self._deliver_idle_menu(now) + else: + log.debug("entering direct dial mode") + # Direct-dial path: first non-zero digit enters DIRECT_DIAL + self._enter_direct_dial(digit, now) + return + + # In all operator-menu states, dispatch the navigation digit immediately + self._dispatch_navigation_digit(digit, now) def tick(self, now: Optional[float] = None) -> None: """Advance timeouts. Call from polling loop.""" @@ -346,54 +362,27 @@ def tick(self, now: Optional[float] = None) -> None: if now is None: now = time.monotonic() - # Check for pending disambiguation timeout - if self._pending_digit is not None: - elapsed = now - self._pending_digit_time - if elapsed >= DIRECT_DIAL_DISAMBIGUATION_TIMEOUT: - digit = self._pending_digit - self._pending_digit = None - self._dispatch_navigation_digit(digit, now) - return + # Dial-entry timeout: covers waiting-for-first-digit (IDLE_DIAL_TONE) and + # accumulating remaining digits (DIRECT_DIAL). Measured from handset lift + # so the total window is fixed regardless of when dialing starts. + if self._state in (MenuState.IDLE_DIAL_TONE, MenuState.DIRECT_DIAL): + if now - self._handset_up_time >= DIAL_ENTRY_TIMEOUT: + self._go_off_hook() + return - # Inactivity timeout - if self._state not in (MenuState.OFF_HOOK, MenuState.IDLE_DIAL_TONE): + # Inactivity timeout for operator-menu states + if self._state != MenuState.OFF_HOOK: if now - self._last_activity_time >= INACTIVITY_TIMEOUT: self._go_off_hook() - return - - # Dial tone timeout → deliver menu - if self._state == MenuState.IDLE_DIAL_TONE: - self._check_dial_tone_timeout(now) # ------------------------------------------------------------------ # State transitions # ------------------------------------------------------------------ - def _check_dial_tone_timeout(self, now: float) -> None: - """Fire the menu prompt after the appropriate dial tone silence.""" - elapsed = now - self._handset_up_time - # Use the playback state captured at handset lift to avoid polling - # MPD on every tick (200 Hz would open/close a TCP connection each time). - playback = self._lift_playback - radio_active = self._radio is not None and self._radio.is_playing() - if playback.item is not None or radio_active: - timeout = DIAL_TONE_TIMEOUT_PLAYING - else: - timeout = DIAL_TONE_TIMEOUT_IDLE - - if elapsed >= timeout: - self._audio.stop() - if playback.item is not None: - self._deliver_playing_menu(playback, now) - elif radio_active: - self._deliver_radio_playing_menu(now) - else: - self._deliver_idle_menu(now) - def _deliver_idle_menu(self, now: float) -> None: """Deliver the idle top-level menu prompt.""" self._last_activity_time = now - + log.debug("nothing playing; deliver idle menu") # Try to load content try: has_playlists = self._media_store.playlists_has_content @@ -404,6 +393,7 @@ def _deliver_idle_menu(self, now: float) -> None: self._media_store.get_playlists() has_playlists = self._media_store.playlists_has_content if not has_artists: + log.info("getting artists") self._media_store.get_artists() has_artists = self._media_store.artists_has_content if not has_genres: @@ -411,6 +401,7 @@ def _deliver_idle_menu(self, now: float) -> None: has_genres = self._media_store.genres_has_content except (sqlite3.Error, OSError): + log.error("unable to access the media store") self._failure_mode = "media" self._state = MenuState.IDLE_MENU self._tts.speak_and_play(SCRIPT_MEDIA_FAILURE) @@ -418,6 +409,7 @@ def _deliver_idle_menu(self, now: float) -> None: return if not has_playlists and not has_artists and not has_genres: + log.debug("no media found") self._state = MenuState.OFF_HOOK self._tts.speak_and_play(SCRIPT_NO_CONTENT) self._audio.play_off_hook_tone() @@ -456,6 +448,7 @@ def _deliver_idle_menu(self, now: float) -> None: def _deliver_playing_menu(self, playback: PlaybackState, now: float) -> None: """Deliver the playing state top-level menu prompt.""" + log.debug("music playing; deliver playing greeting") self._last_activity_time = now self._state = MenuState.PLAYING_MENU @@ -484,6 +477,7 @@ def _deliver_playing_menu(self, playback: PlaybackState, now: float) -> None: def _deliver_radio_playing_menu(self, now: float) -> None: """Deliver the radio playing state menu prompt.""" + log.debug("radio playing; deliver radio playing menu") self._state = MenuState.RADIO_PLAYING_MENU self._last_activity_time = now @@ -522,21 +516,9 @@ def _handle_radio_playing_menu_digit(self, digit: int, now: float) -> None: # ------------------------------------------------------------------ def _dispatch_navigation_digit(self, digit: int, now: float) -> None: - """Handle a single confirmed navigation digit.""" + """Handle a navigation digit in an operator-menu state.""" self._last_activity_time = now - # Guard: digit dialed during IDLE_DIAL_TONE (before menu delivered). - # Deliver the appropriate menu first, stop the dial tone, then drop the - # digit — the user dialed before hearing the options and must dial again. - if self._state == MenuState.IDLE_DIAL_TONE: - self._audio.stop() - playback = self._media_client.now_playing() - if playback.item is not None: - self._deliver_playing_menu(playback, now) - else: - self._deliver_idle_menu(now) - return - # RADIO_PLAYING_MENU handles its own digit routing (before global 0/9 rules) if self._state == MenuState.RADIO_PLAYING_MENU: self._handle_radio_playing_menu_digit(digit, now) @@ -625,6 +607,7 @@ def _handle_idle_menu_digit(self, digit: int, now: float) -> None: SCRIPT_BROWSE_PROMPT_PLAYLIST, now) elif next_state == MenuState.BROWSE_ARTISTS: items = self._media_store.get_artists() + log.info("browing artists: " + str(items)) self._start_browse(items, MenuState.BROWSE_ARTISTS, SCRIPT_BROWSE_PROMPT_ARTIST, now) elif next_state == MenuState.BROWSE_GENRES: @@ -671,6 +654,7 @@ def _handle_browse_digit(self, digit: int, now: float, media_type: str) -> None: return # T9 narrowing mode + log.info("narrowing for " + str(digit)) self._browse_prefix.append(digit) filtered = _filter_by_t9_prefix(self._browse_items, self._browse_prefix) @@ -754,16 +738,12 @@ def _select_item(self, item: MediaItem, media_type: str, now: float) -> None: # Direct dial # ------------------------------------------------------------------ - def _enter_direct_dial(self, first: int, second: int, now: float) -> None: - """Enter DIRECT_DIAL mode with the first two digits.""" - self._pre_dial_state = self._state + def _enter_direct_dial(self, first: int, now: float) -> None: + """Enter DIRECT_DIAL mode with the first digit (already non-zero).""" self._state = MenuState.DIRECT_DIAL self._dial_digits = [] - self._audio.stop() self._audio.play_dtmf(first) self._dial_digits.append(first) - self._audio.play_dtmf(second) - self._dial_digits.append(second) self._last_activity_time = now def _handle_direct_dial_digit(self, digit: int, now: float) -> None: @@ -779,7 +759,7 @@ def _handle_direct_dial_digit(self, digit: int, now: float) -> None: def _execute_direct_dial(self, now: float) -> None: """Look up and play the phone number.""" number = "".join(str(d) for d in self._dial_digits) - + log.info("dialing " + number) # Route to diagnostic assistant if number == ASSISTANT_NUMBER: self._enter_assistant(now) @@ -793,17 +773,13 @@ def _execute_direct_dial(self, now: float) -> None: if entry is None: self._tts.speak_and_play(SCRIPT_NOT_IN_SERVICE) - pre = self._pre_dial_state or MenuState.IDLE_MENU - if pre == MenuState.IDLE_DIAL_TONE: - # User dialed before any menu was delivered — determine correct top-level menu - playback = self._media_client.now_playing() - if playback.item is not None: - self._deliver_playing_menu(playback, now) - else: - self._deliver_idle_menu(now) + # Direct dial is only reachable from IDLE_DIAL_TONE, so always + # deliver the appropriate top-level menu on failure. + playback = self._media_client.now_playing() + if playback.item is not None: + self._deliver_playing_menu(playback, now) else: - self._state = pre - self._re_deliver_current_state(now) + self._deliver_idle_menu(now) return if entry["media_type"] == "radio": @@ -1047,5 +1023,6 @@ def _deliver_assistant_redirect(self, now: float) -> None: def _go_off_hook(self) -> None: """Enter the off-hook warning state.""" + log.debug("changing to off hook") self._state = MenuState.OFF_HOOK self._audio.play_off_hook_tone() diff --git a/src/mpd_client.py b/src/mpd_client.py index 1b77304..7db414f 100644 --- a/src/mpd_client.py +++ b/src/mpd_client.py @@ -15,6 +15,8 @@ from contextlib import contextmanager from typing import Optional +import logging +import json import mpd # python-mpd2 @@ -26,6 +28,7 @@ _GENRE_PREFIX = "genre:" _TRACK_PREFIX = "track:" +log = logging.getLogger("mpd") def _strip(prefix: str, value: str) -> str: return value[len(prefix):] if value.startswith(prefix) else value @@ -37,6 +40,7 @@ class MPDClient(MediaClientInterface): def __init__(self, host: str = "localhost", port: int = 6600) -> None: self._host = host self._port = port + self._client = mpd.MPDClient() @contextmanager def _connection(self): @@ -69,13 +73,16 @@ def get_playlists(self) -> list: ] def get_artists(self) -> list: + log.info("getting artists") with self._connection() as c: - names = c.list("albumartist") - return [ - MediaItem(media_key=f"{_ARTIST_PREFIX}{name}", name=name, media_type="artist") - for name in names - if name - ] + artists = c.list("albumartist") + log.info("artists: " + str(artists)) + result = [] + for artist in artists: + name = artist['albumartist'] + if name: + result.append(MediaItem(media_key=f"{_ARTIST_PREFIX}{name}", name=name, media_type="artist")) + return result def get_genres(self) -> list: with self._connection() as c: @@ -137,8 +144,10 @@ def stop(self) -> None: c.stop() def now_playing(self) -> PlaybackState: + log.info("checking what's playing") with self._connection() as c: status = c.status() + log.info("status is " + str(status)) state = status.get("state", "stop") if state == "stop": return PlaybackState(item=None, is_paused=False) diff --git a/src/phone.py b/src/phone.py new file mode 100644 index 0000000..823508c --- /dev/null +++ b/src/phone.py @@ -0,0 +1,90 @@ +import threading +import time +import logging + +from gpiozero import Button, DigitalInputDevice +from src.audio import SounddeviceAudio +from src.tts import PiperTTS +from src.menu import Menu +from src.dialer import Dialer + +log = logging.getLogger(__name__) + +class Phone: + + def __init__( + self, + hook: DigitalInputDevice, + pulse: Button, + rotary: DigitalInputDevice, + tts: PiperTTS, + audio: SounddeviceAudio, + menu: Menu + ) -> None: + self._handset_lifted = False + self._hook = hook + self._stop_event = threading.Event() + self._audio = audio + self._menu = menu + self._dialer = Dialer(self._menu.on_digit, pulse, rotary) + + + def start(self) -> None: + t = threading.Thread(target=self._start_hook_watcher, daemon=True, name="hook-watcher") + t.start() + + def stop(self) -> None: + self._on_handset_replaced() + self._stop_event.set() + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _start_hook_watcher(self) -> None: + """Spin a daemon thread that listens to the hook pin at ~1 ms intervals. + Drives the amp and creates/closes Session the instant the pin changes + state, bypassing the polling loop's debounce delay. + """ + log.info("hook-watcher thread starting") + self._hook.when_activated = self._on_handset_lifted + self._hook.when_deactivated = self._on_handset_replaced + while not self._stop_event.is_set(): + try: + log.info("hook-watcher waiting for hook lift") + self._hook.wait_for_active() + log.info("hook-watcher waiting for hook replace") + self._hook.wait_for_inactive() + except Exception: + log.exception("hook-watcher error") + log.info("hook-watcher thread exiting") + + def _start_phone_session(self) -> None: + log.info("phone session starting") + while self._handset_lifted: + try: + now = time.monotonic() + self._menu.tick(now=now) + except Exception: + log.exception("phone session error") + time.sleep(0.005) + log.info("phone session stopped") + + def _on_handset_lifted(self) -> None: + if not self._handset_lifted: + self._handset_lifted = True + log.info("handset lifted - starting session") + self._audio.amp_on() + self._dialer.start() + self._menu.on_handset_lifted() + threading.Thread( + target=self._start_phone_session, daemon=True, name="phone-session" + ).start() + + def _on_handset_replaced(self) -> None: + if self._handset_lifted: + self._handset_lifted = False + log.info("handset on cradle - ending session") + self._audio.amp_off() + self._dialer.stop() + self._menu.on_handset_on_cradle() diff --git a/src/phone_book.py b/src/phone_book.py index d3c6910..8fe14f7 100644 --- a/src/phone_book.py +++ b/src/phone_book.py @@ -43,15 +43,18 @@ def _init_db(self) -> None: def _generate_unique_number(self, conn: sqlite3.Connection) -> str: """Generate a random PHONE_NUMBER_LENGTH-digit number not already in use. - Raises RuntimeError if a unique number cannot be found within - PHONE_NUMBER_GENERATE_MAX_ATTEMPTS iterations. + Numbers never start with 0 (reserved for the operator shortcut) and never + equal ASSISTANT_NUMBER. Raises RuntimeError if a unique number cannot be + found within PHONE_NUMBER_GENERATE_MAX_ATTEMPTS iterations. """ - min_val = 10 ** (PHONE_NUMBER_LENGTH - 1) + min_val = 10 ** (PHONE_NUMBER_LENGTH - 1) # e.g. 1000000 — already no leading zero max_val = (10 ** PHONE_NUMBER_LENGTH) - 1 for _ in range(PHONE_NUMBER_GENERATE_MAX_ATTEMPTS): candidate = str(random.randint(min_val, max_val)) if candidate == ASSISTANT_NUMBER: continue + if candidate[0] == '0': # defensive; min_val already prevents this + continue exists = conn.execute( "SELECT 1 FROM phone_book WHERE phone_number = ?", (candidate,) ).fetchone() @@ -99,8 +102,14 @@ def lookup_by_phone_number(self, phone_number: str) -> Optional[dict]: def seed(self, phone_number: str, media_key: str, media_type: str, name: str) -> None: """Insert a pre-configured entry if the phone number is not already present. + Phone numbers must not start with 0 (reserved for the operator shortcut). Idempotent: silently skips if phone_number or media_key already exists. """ + if phone_number.startswith('0'): + raise ValueError( + f"Phone number {phone_number!r} must not start with 0 " + "(0 is reserved for the operator shortcut)." + ) with self._connect() as conn: exists = conn.execute( "SELECT 1 FROM phone_book WHERE phone_number = ?", (phone_number,) diff --git a/src/session.py b/src/session.py deleted file mode 100644 index 8f6a5cb..0000000 --- a/src/session.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Session lifecycle for hello-operator. - -Manages a single handset interaction. Created when the handset is lifted; -closed when it is replaced. Owns the GPIO digit-polling loop. - -Production usage (via hook watcher in main.py):: - - menu = Menu(...) - session = Session(menu=menu, gpio=gpio_handler) - session.start() # starts internal polling thread - # ... on hang-up: - session.close() - -Test usage (no background thread):: - - session = Session(menu=menu, now=0.0) - session.handle_event((GpioEvent.DIGIT_DIALED, 5), now=0.5) - session.tick(now=DIAL_TONE_TIMEOUT_IDLE + 1.0) - session.close() -""" - -import threading -import time -from typing import Optional, Tuple, Union - -from src.gpio_handler import GpioEvent -from src.menu import Menu - - -class Session: - """Manages a single handset interaction. - - Parameters - ---------- - menu: - Pre-constructed Menu instance owned by the caller. - gpio: - GPIOHandler for the internal digit-polling loop. Omit in tests. - now: - Monotonic clock value for the handset-lifted event. Defaults to - ``time.monotonic()`` if not provided. - """ - - def __init__( - self, - menu: Menu, - gpio=None, - now: Optional[float] = None, - ) -> None: - if now is None: - now = time.monotonic() - self._menu = menu - self._gpio = gpio - self._stop_event = threading.Event() - self._menu.on_handset_lifted(now=now) - - def start(self) -> None: - """Start the internal GPIO digit-polling loop in a daemon thread. - - Call this in production after construction. Tests omit this and - drive the session directly via handle_event() and tick(). - """ - t = threading.Thread(target=self._run, daemon=True, name="session-poll") - t.start() - - def close(self) -> None: - """Stop polling and deliver the hang-up event to the menu.""" - self._stop_event.set() - self._menu.on_handset_on_cradle() - - def handle_event( - self, - event: Union[GpioEvent, Tuple[GpioEvent, int]], - now: Optional[float] = None, - ) -> None: - """Dispatch a digit event directly (used in tests). - - Only DIGIT_DIALED events are processed; hook events are ignored - since the hook watcher handles those in production. - """ - if now is None: - now = time.monotonic() - if isinstance(event, tuple) and event[0] == GpioEvent.DIGIT_DIALED: - self._menu.on_digit(event[1], now=now) - - def tick(self, now: Optional[float] = None) -> None: - """Advance menu timeouts (used in tests).""" - if now is None: - now = time.monotonic() - self._menu.tick(now=now) - - @property - def menu(self) -> Menu: - """The underlying Menu instance (for state inspection in tests).""" - return self._menu - - # ------------------------------------------------------------------ - # Internal - # ------------------------------------------------------------------ - - def _run(self) -> None: - while not self._stop_event.is_set(): - now = time.monotonic() - event = self._gpio.poll(now=now) - if isinstance(event, tuple) and event[0] == GpioEvent.DIGIT_DIALED: - self._menu.on_digit(event[1], now=now) - self._menu.tick(now=now) - time.sleep(0.005) diff --git a/tests/test_gpio_handler.py b/tests/test_gpio_handler.py index 7eb96f6..bc490bf 100644 --- a/tests/test_gpio_handler.py +++ b/tests/test_gpio_handler.py @@ -1,97 +1,33 @@ -"""Tests for src/gpio_handler.py — GPIOHandler hardware abstraction.""" +"""Tests for src/gpio_handler.py — GPIOHandler pulse decoder.""" import pytest from src.gpio_handler import GPIOHandler, GpioEvent -from src.constants import HOOK_DEBOUNCE, PULSE_DEBOUNCE, INTER_DIGIT_TIMEOUT +from src.constants import PULSE_DEBOUNCE, INTER_DIGIT_TIMEOUT # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- -def make_handler(hook_pin_reader=None, pulse_pin_reader=None): - """Build a GPIOHandler with injectable pin-reader callables.""" - return GPIOHandler( - hook_pin_reader=hook_pin_reader or (lambda: 1), - pulse_pin_reader=pulse_pin_reader or (lambda: 1), - ) +def make_handler(pulse_pin_reader=None): + """Build a GPIOHandler with an injectable pulse-reader callable.""" + return GPIOHandler(pulse_pin_reader=pulse_pin_reader or (lambda: 1)) -def poll_at(handler, hook_val, pulse_val, t): - """Single poll at fake time t, overriding pin readers inline.""" - handler._hook_reader = lambda: hook_val +def poll_at(handler, pulse_val, t): + """Single poll at fake time t, overriding the pulse reader inline.""" handler._pulse_reader = lambda: pulse_val return handler.poll(now=t) # --------------------------------------------------------------------------- -# 1.1 Hook switch -# --------------------------------------------------------------------------- - -class TestHookSwitch: - - def test_hook_lifted(self): - """GPIO LOW (0) stable for debounce window → emits HANDSET_LIFTED.""" - handler = make_handler() - t = 0.0 - # First reading: LOW appears (candidate starts) - poll_at(handler, hook_val=0, pulse_val=1, t=t) - # After debounce window: still LOW → commit → HANDSET_LIFTED - event = poll_at(handler, hook_val=0, pulse_val=1, t=t + HOOK_DEBOUNCE) - assert event == GpioEvent.HANDSET_LIFTED - - def test_hook_on_cradle(self): - """GPIO HIGH stable after being lifted → emits HANDSET_ON_CRADLE.""" - handler = make_handler() - t = 0.0 - # Lift handset - poll_at(handler, hook_val=0, pulse_val=1, t=t) - poll_at(handler, hook_val=0, pulse_val=1, t=t + HOOK_DEBOUNCE) - # Replace handset - t2 = t + HOOK_DEBOUNCE + 0.1 - poll_at(handler, hook_val=1, pulse_val=1, t=t2) - event = poll_at(handler, hook_val=1, pulse_val=1, t=t2 + HOOK_DEBOUNCE) - assert event == GpioEvent.HANDSET_ON_CRADLE - - def test_hook_debounce(self): - """Rapid HIGH/LOW transitions within debounce window → only one event.""" - handler = make_handler() - t = 0.0 - dt = HOOK_DEBOUNCE * 0.1 # much shorter than debounce window - - # Sequence: HIGH resting, bounces LOW/HIGH/LOW quickly, then settles LOW - results = [] - # Bounce 1: LOW - results.append(poll_at(handler, hook_val=0, pulse_val=1, t=t)) - t += dt - # Bounce 2: HIGH (within window — resets candidate) - results.append(poll_at(handler, hook_val=1, pulse_val=1, t=t)) - t += dt - # Bounce 3: LOW (within window again) - results.append(poll_at(handler, hook_val=0, pulse_val=1, t=t)) - t += dt - # Settle LOW for full debounce window - results.append(poll_at(handler, hook_val=0, pulse_val=1, t=t + HOOK_DEBOUNCE)) - - lifted_events = [e for e in results if e == GpioEvent.HANDSET_LIFTED] - assert len(lifted_events) == 1 - - def test_hook_no_event_when_state_unchanged(self): - """Repeated reads of same HIGH state → no duplicate events.""" - handler = make_handler(hook_pin_reader=lambda: 1) - # Initial state is HIGH (on cradle); poll many times with HIGH - events = [handler.poll(now=float(i) * 0.1) for i in range(10)] - assert all(e is None for e in events) - - -# --------------------------------------------------------------------------- -# 1.2 Pulse switch / dial decoder +# Pulse switch / dial decoder # --------------------------------------------------------------------------- class TestPulseDecoder: """ Pulse timing is simulated by injecting fake timestamps via handler.poll(now=t). - Each pulse is LOW for ~20 ms (above PULSE_DEBOUNCE), HIGH for ~60 ms between + Each pulse is LOW for ~25 ms (above PULSE_DEBOUNCE), HIGH for ~60 ms between pulses. After the last pulse, a gap of INTER_DIGIT_TIMEOUT + margin triggers the DIGIT_DIALED event. """ @@ -100,38 +36,24 @@ class TestPulseDecoder: PULSE_HIGH_MS = 0.060 # 60 ms HIGH between pulses def _build_pulse_sequence(self, num_pulses, start_t=0.0): - """ - Return list of (hook_val, pulse_val, timestamp) for `num_pulses` pulses - followed by an inter-digit gap. - """ + """Return list of (pulse_val, timestamp) for `num_pulses` pulses + followed by an inter-digit gap.""" events = [] t = start_t for _ in range(num_pulses): - events.append((0, 0, t)) # pulse start (LOW) + events.append((0, t)) # pulse LOW t += self.PULSE_LOW_MS - events.append((0, 1, t)) # pulse end (HIGH) + events.append((1, t)) # pulse HIGH t += self.PULSE_HIGH_MS - # After last pulse, add idle beyond inter-digit timeout - events.append((0, 1, t + INTER_DIGIT_TIMEOUT + 0.05)) + # Idle beyond inter-digit timeout + events.append((1, t + INTER_DIGIT_TIMEOUT + 0.05)) return events def _run_sequence(self, seq): - """Drive a GPIOHandler through (hook_val, pulse_val, t) triples. - - A two-poll hook-settling prefix is prepended automatically at t=-0.2 - and t=-0.1 (before any sequence timestamps) so that the hook switch is - fully committed to 'lifted' before the first pulse arrives. - """ + """Drive a GPIOHandler through (pulse_val, t) pairs.""" handler = make_handler() collected = [] - # Settle hook to 'lifted' before pulses begin - for pre_t in (-0.2, -0.2 + HOOK_DEBOUNCE): - handler._hook_reader = lambda: 0 - handler._pulse_reader = lambda: 1 - handler.poll(now=pre_t) - - for hook_val, pulse_val, t in seq: - handler._hook_reader = lambda h=hook_val: h + for pulse_val, t in seq: handler._pulse_reader = lambda p=pulse_val: p event = handler.poll(now=t) if event is not None: @@ -172,57 +94,130 @@ def test_pulse_burst_timeout(self): def test_multiple_digits_sequence(self): """Two bursts separated by timeout → two DIGIT_DIALED events in order.""" seq1 = self._build_pulse_sequence(3, start_t=0.0) - t_offset = seq1[-1][2] + 0.2 # start well after first digit settles + t_offset = seq1[-1][1] + 0.2 seq2 = self._build_pulse_sequence(7, start_t=t_offset) - full_seq = seq1 + seq2 - events = self._run_sequence(full_seq) + events = self._run_sequence(seq1 + seq2) digit_events = [e for e in events if isinstance(e, tuple) and e[0] == GpioEvent.DIGIT_DIALED] assert len(digit_events) == 2 assert digit_events[0][1] == 3 assert digit_events[1][1] == 7 def test_pulse_debounce(self): - """Noise pulses shorter than minimum pulse width → ignored.""" + """Noise pulses shorter than PULSE_DEBOUNCE → ignored.""" handler = make_handler() - - # Handset is lifted - handler._hook_reader = lambda: 0 - - t = 0.0 - # Very short LOW (less than PULSE_DEBOUNCE — noise) - noise_duration = PULSE_DEBOUNCE * 0.4 - events = [] + t = 0.0 - # Stable HIGH initially - handler._pulse_reader = lambda: 1 - events.append(handler.poll(now=t)) + # Stable HIGH + events.append(poll_at(handler, pulse_val=1, t=t)) # Brief LOW — noise spike t += 0.01 - handler._pulse_reader = lambda: 0 - events.append(handler.poll(now=t)) + events.append(poll_at(handler, pulse_val=0, t=t)) - # Back HIGH quickly (within noise threshold) - t += noise_duration - handler._pulse_reader = lambda: 1 - events.append(handler.poll(now=t)) + # Back HIGH within noise threshold + t += PULSE_DEBOUNCE * 0.4 + events.append(poll_at(handler, pulse_val=1, t=t)) - # Long idle → should NOT emit a digit + # Long idle — should NOT emit a digit t += INTER_DIGIT_TIMEOUT + 0.1 - events.append(handler.poll(now=t)) + events.append(poll_at(handler, pulse_val=1, t=t)) digit_events = [e for e in events if isinstance(e, tuple) and e[0] == GpioEvent.DIGIT_DIALED] assert digit_events == [] - def test_dial_ignored_when_hook_on_cradle(self): - """Pulses while handset on cradle (hook HIGH) → no digit events emitted.""" - # Build a valid pulse sequence (hook_val=0 = lifted), then change to on-cradle - seq_lifted = self._build_pulse_sequence(5) - # Change all hook_val to 1 (on cradle) - seq_cradle = [(1, pulse_val, t) for _, pulse_val, t in seq_lifted] + def test_no_event_when_idle(self): + """Repeated reads of resting HIGH → no events.""" + handler = make_handler(pulse_pin_reader=lambda: 1) + events = [handler.poll(now=float(i) * 0.1) for i in range(10)] + assert all(e is None for e in events) + + +# --------------------------------------------------------------------------- +# Background polling thread: start / stop / drain_digits +# --------------------------------------------------------------------------- + +class TestGPIOHandlerThread: + """Tests for the dedicated 1 ms polling thread interface.""" + + def test_drain_digits_empty_before_start(self): + """drain_digits() returns [] when no digits have been queued.""" + handler = make_handler() + assert handler.drain_digits() == [] + + def test_start_and_stop_do_not_raise(self): + """start() launches thread without error; stop() signals clean exit.""" + import time + handler = make_handler(pulse_pin_reader=lambda: 1) + handler.start() + time.sleep(0.02) + handler.stop() + + def test_thread_detects_digit_via_drain(self): + """Pulse sequence fed through _process_pulse with fake clock → digit decoded.""" + from src.constants import INTER_DIGIT_TIMEOUT + + PULSE_LOW = 0.025 # 25 ms LOW + PULSE_HIGH = 0.060 # 60 ms HIGH between pulses + + # 3 pulses: LOW/HIGH pairs, then idle beyond INTER_DIGIT_TIMEOUT + events = [] + handler = make_handler() + t = 0.0 + for _ in range(3): + events.append(handler._process_pulse(0, t)); t += PULSE_LOW + events.append(handler._process_pulse(1, t)); t += PULSE_HIGH + + # Drive idle ticks until inter-digit timeout fires + step = 0.010 + limit = t + INTER_DIGIT_TIMEOUT + 0.2 + while t < limit: + ev = handler._process_pulse(1, t) + if ev is not None: + events.append(ev) + break + t += step - events = self._run_sequence(seq_cradle) digit_events = [e for e in events if isinstance(e, tuple) and e[0] == GpioEvent.DIGIT_DIALED] - assert digit_events == [] + assert len(digit_events) == 1 + assert digit_events[0][1] == 3 + + def test_start_resets_stale_queue(self): + """start() drains any digits left from a prior session.""" + import time + handler = make_handler(pulse_pin_reader=lambda: 1) + # Manually stuff a stale digit into the queue + handler._digit_queue.put((9, 0.0)) + assert len(handler.drain_digits()) == 1 + + # Second start() on a fresh handler should drain queue first + handler2 = make_handler(pulse_pin_reader=lambda: 1) + handler2._digit_queue.put((7, 0.0)) + handler2.start() + time.sleep(0.01) + handler2.stop() + # After start(), the stale 7 should have been drained internally; + # any new digits are from the real thread (none expected for idle HIGH). + digits = handler2.drain_digits() + assert all(d != 7 for d, _ in digits) + + def test_stop_halts_thread(self): + """stop() causes the polling thread to exit within 50 ms.""" + import time + poll_count = [0] + + def counting_reader(): + poll_count[0] += 1 + return 1 + + handler = GPIOHandler(pulse_pin_reader=counting_reader) + handler.start() + time.sleep(0.02) + count_at_stop = poll_count[0] + handler.stop() + time.sleep(0.05) + count_after_stop = poll_count[0] + # Some polls may fire in the 50 ms window, but the count should not + # grow by more than ~1-2 after stop() was called. + assert count_after_stop - count_at_stop <= 5 diff --git a/tests/test_main.py b/tests/test_main.py index 828c989..2a7a2f0 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -32,33 +32,22 @@ def test_piper_tts_accepts_piper_model_kwarg(mock_audio, mock_error_queue, tmp_p assert tts is not None -def test_gpio_handler_accepts_pin_reader_kwargs(): - """GPIOHandler constructor must accept hook_pin_reader= and pulse_pin_reader= - (not hook_reader=, pulse_reader=, hook_debounce=, pulse_debounce=). - """ +def test_gpio_handler_accepts_pulse_pin_reader_kwarg(): + """GPIOHandler constructor must accept pulse_pin_reader= only.""" from src.gpio_handler import GPIOHandler - hook_reader = lambda: 1 - pulse_reader = lambda: 1 - - # Should not raise TypeError - handler = GPIOHandler( - hook_pin_reader=hook_reader, - pulse_pin_reader=pulse_reader, - ) + handler = GPIOHandler(pulse_pin_reader=lambda: 1) assert handler is not None def test_gpio_handler_rejects_old_kwargs(): - """GPIOHandler must NOT accept the old wrong keyword argument names.""" + """GPIOHandler must NOT accept old keyword argument names.""" from src.gpio_handler import GPIOHandler with pytest.raises(TypeError): GPIOHandler( - hook_reader=lambda: 1, - pulse_reader=lambda: 1, - hook_debounce=0.05, - pulse_debounce=0.005, + hook_pin_reader=lambda: 1, + pulse_pin_reader=lambda: 1, ) @@ -95,7 +84,7 @@ def _run_with_stubs(makedirs_mock=None): patch("src.main.SounddeviceAudio", MagicMock()), patch("src.main.PiperTTS", MagicMock()), patch("src.main.MediaStore", MagicMock()), - patch("src.main.build_gpio_handler", MagicMock()), + patch("src.main._gpio_setup", MagicMock(side_effect=True)), patch("src.main.Session", MagicMock()), patch("src.main.time.sleep", side_effect=KeyboardInterrupt), ] @@ -143,7 +132,7 @@ def test_run_makedirs_called_before_sqlite_error_queue(): patch("src.main.SounddeviceAudio", MagicMock()), \ patch("src.main.PiperTTS", MagicMock()), \ patch("src.main.MediaStore", MagicMock()), \ - patch("src.main.build_gpio_handler", MagicMock()), \ + patch("src.main._gpio_setup", MagicMock(side_effect=True)), \ patch("src.main.Session", MagicMock()), \ patch("src.main.time.sleep", side_effect=KeyboardInterrupt): try: @@ -173,7 +162,7 @@ def test_run_makedirs_exist_ok_true(): patch("src.main.SounddeviceAudio", MagicMock()), \ patch("src.main.PiperTTS", MagicMock()), \ patch("src.main.MediaStore", MagicMock()), \ - patch("src.main.build_gpio_handler", MagicMock()), \ + patch("src.main._gpio_setup", MagicMock(side_effect=True)), \ patch("src.main.Session", MagicMock()), \ patch("src.main.time.sleep", side_effect=KeyboardInterrupt): try: @@ -188,50 +177,45 @@ def test_run_makedirs_exist_ok_true(): # F-18: GPIO.cleanup() called on clean shutdown # --------------------------------------------------------------------------- -def _run_with_gpio_cleanup_mock(gpio_cleanup_mock, build_gpio_raises=False): +def _run_with_gpio_cleanup_mock(gpio_cleanup_mock, gpio_setup_success=True): """Run main.run() with all heavy dependencies stubbed out, capturing GPIO.cleanup() calls via the provided mock. - If build_gpio_raises is True, build_gpio_handler() raises ImportError + If gpio_setup_success is False, _gpio_setup() returns False (simulating a missing RPi.GPIO module). """ import src.main as main_mod - if build_gpio_raises: - build_gpio_side_effect = ImportError("No module named 'RPi'") - else: - build_gpio_side_effect = None - with patch("src.main.os.makedirs", MagicMock()), \ patch("src.main.SqliteErrorQueue", MagicMock()), \ patch("src.main.PhoneBook", MagicMock()), \ patch("src.main.SounddeviceAudio", MagicMock()), \ patch("src.main.PiperTTS", MagicMock()), \ patch("src.main.MediaStore", MagicMock()), \ - patch("src.main.build_gpio_handler", - MagicMock(side_effect=build_gpio_side_effect)), \ + patch("src.main._gpio_setup", + MagicMock(side_effect=gpio_setup_success)), \ patch("src.main.Session", MagicMock()), \ patch("src.main.time.sleep", side_effect=KeyboardInterrupt), \ patch("src.main._gpio_cleanup", gpio_cleanup_mock): try: main_mod.run() - except (KeyboardInterrupt, ImportError): + except (KeyboardInterrupt): pass def test_gpio_cleanup_called_on_keyboard_interrupt(): """run() must call _gpio_cleanup() in the finally block when - build_gpio_handler() succeeds.""" + _gpio_setup() succeeds.""" gpio_cleanup_mock = MagicMock() - _run_with_gpio_cleanup_mock(gpio_cleanup_mock, build_gpio_raises=False) + _run_with_gpio_cleanup_mock(gpio_cleanup_mock, gpio_setup_success=True) gpio_cleanup_mock.assert_called_once() def test_gpio_cleanup_not_called_when_build_raises(): - """run() must NOT call _gpio_cleanup() if build_gpio_handler() raised + """run() must NOT call _gpio_cleanup() if _gpio_setup() fails (i.e., GPIO was never initialised).""" gpio_cleanup_mock = MagicMock() - _run_with_gpio_cleanup_mock(gpio_cleanup_mock, build_gpio_raises=True) + _run_with_gpio_cleanup_mock(gpio_cleanup_mock, gpio_setup_success=False) gpio_cleanup_mock.assert_not_called() @@ -253,7 +237,7 @@ def test_gpio_cleanup_called_after_audio_stop(): patch("src.main.SounddeviceAudio", audio_class_mock), \ patch("src.main.PiperTTS", MagicMock()), \ patch("src.main.MediaStore", MagicMock()), \ - patch("src.main.build_gpio_handler", MagicMock()), \ + patch("src.main._gpio_setup", MagicMock(side_effect=True)), \ patch("src.main.Session", MagicMock()), \ patch("src.main.time.sleep", side_effect=KeyboardInterrupt), \ patch("src.main._gpio_cleanup", gpio_cleanup_mock): diff --git a/tests/test_menu.py b/tests/test_menu.py index 21d7b91..82453f5 100644 --- a/tests/test_menu.py +++ b/tests/test_menu.py @@ -8,7 +8,7 @@ from src.menu import Menu, MenuState, _t9_digit_for_char, _t9_digit_for_name, _filter_by_t9_prefix from src.interfaces import MediaItem, PlaybackState from src.constants import ( - DIRECT_DIAL_DISAMBIGUATION_TIMEOUT, DIAL_TONE_TIMEOUT_IDLE, INACTIVITY_TIMEOUT, + DIAL_ENTRY_TIMEOUT, INACTIVITY_TIMEOUT, ASSISTANT_NUMBER, ASSISTANT_MESSAGE_PAGE_SIZE, PHONE_NUMBER_LENGTH, ) @@ -55,133 +55,161 @@ def tts_calls(mock_tts): # --------------------------------------------------------------------------- -# §9.1 Reserved digits and disambiguation +# §9.1 Dialing paths from IDLE_DIAL_TONE # --------------------------------------------------------------------------- -class TestReservedDigitsAndDisambiguation: +class TestDialingPaths: - def test_digit_0_goes_back_one_level_or_top(self, mock_audio, mock_tts, mock_media_client, - mock_media_store, mock_error_queue, tmp_path): - """Digit 0 alone → go back one level; at root delivers the top-level menu.""" + def test_digit_0_from_idle_dial_tone_delivers_operator_menu( + self, mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path): + """Digit 0 from IDLE_DIAL_TONE → operator menu delivered immediately.""" mock_media_store.set_playlists([MediaItem("/p/1", "Jazz Mix", "playlist")]) mock_media_client.set_now_playing(PlaybackState(item=None, is_paused=False)) menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_handset_lifted(now=_T0) - menu.on_digit(0, now=0.0) - # After disambiguation timeout: no history at root → delivers top-level menu - menu.tick(now=0.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + assert menu.state == MenuState.IDLE_DIAL_TONE + menu.on_digit(0, now=0.5) assert menu.state in (MenuState.IDLE_MENU, MenuState.PLAYING_MENU) - def test_digit_0_goes_back_from_browse(self, mock_audio, mock_tts, mock_media_client, - mock_media_store, mock_error_queue, tmp_path): - """Digit 0 from inside a browse state → go back one level to IDLE_MENU.""" - mock_media_store.set_playlists([MediaItem("/p/1", "Jazz Mix", "playlist")]) - mock_media_client.set_now_playing(PlaybackState(item=None, is_paused=False)) + def test_first_nonzero_digit_enters_direct_dial_immediately( + self, mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path): + """First non-zero digit from IDLE_DIAL_TONE → DIRECT_DIAL entered immediately.""" menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_handset_lifted(now=_T0) - menu.tick(now=10.0) # past dial tone timeout → IDLE_MENU - # Navigate into playlist browse - menu.on_digit(1, now=10.1) - menu.tick(now=10.1 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) - assert menu.state == MenuState.BROWSE_PLAYLISTS - # Digit 0 → back one level - menu.on_digit(0, now=11.0) - menu.tick(now=11.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) - assert menu.state in (MenuState.IDLE_MENU, MenuState.PLAYING_MENU) + assert menu.state == MenuState.IDLE_DIAL_TONE + menu.on_digit(5, now=0.5) + assert menu.state == MenuState.DIRECT_DIAL - def test_digit_9_is_not_navigation_in_browse(self, mock_audio, mock_tts, mock_media_client, - mock_media_store, mock_error_queue, tmp_path): - """Digit 9 is a T9 browse group, not a navigation key — pressing it narrows browse.""" + def test_digit_0_from_idle_dial_tone_stops_dial_tone( + self, mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path): + """Digit 0 from IDLE_DIAL_TONE → dial tone stopped before menu delivery.""" mock_media_store.set_playlists([MediaItem("/p/1", "Jazz Mix", "playlist")]) mock_media_client.set_now_playing(PlaybackState(item=None, is_paused=False)) menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_handset_lifted(now=_T0) - menu.tick(now=10.0) - # Navigate into playlist browse - menu.on_digit(1, now=10.1) - menu.tick(now=10.1 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) - assert menu.state == MenuState.BROWSE_PLAYLISTS - # Digit 9 → T9 narrowing attempt, stays in browse (no match for "Jazz Mix" in group 9) - menu.on_digit(9, now=11.0) - menu.tick(now=11.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) - assert menu.state == MenuState.BROWSE_PLAYLISTS, ( - "Digit 9 should be a T9 browse digit, not navigation-back. " - f"State is {menu.state!r}; expected BROWSE_PLAYLISTS." - ) + mock_audio.calls.clear() + menu.on_digit(0, now=0.5) + stop_calls = [c for c in mock_audio.calls if c[0] == 'stop'] + assert stop_calls, f"Expected audio.stop() before menu delivery; calls: {mock_audio.calls}" - def test_digit_9_at_top_level_is_treated_as_menu_digit(self, mock_audio, mock_tts, - mock_media_client, mock_media_store, - mock_error_queue, tmp_path): - """Digit 9 at IDLE_MENU is treated as a menu option digit, not navigation.""" - mock_media_store.set_playlists([MediaItem("/p/1", "Jazz", "playlist")]) - mock_media_client.set_now_playing(PlaybackState(item=None, is_paused=False)) + def test_digit_0_delivers_playing_menu_when_music_active( + self, mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path): + """Digit 0 while music playing → PLAYING_MENU delivered.""" + playing = MediaItem("/t/1", "Abbey Road", "album") + mock_media_client.set_now_playing(PlaybackState(item=playing, is_paused=False)) + mock_media_client.set_queue_position(1, 5) menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_handset_lifted(now=_T0) - menu.tick(now=10.0) - menu.on_digit(9, now=10.1) - menu.tick(now=10.1 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) - # Option 9 doesn't exist → "not in service", stays at IDLE_MENU - assert menu.state == MenuState.IDLE_MENU + menu.on_digit(0, now=0.5) + assert menu.state == MenuState.PLAYING_MENU - def test_disambiguation_timeout_single_digit_is_navigation( + def test_dial_entry_timeout_no_digits_triggers_off_hook( self, mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path): - """First digit received, no second digit within timeout → treated as navigation.""" - mock_media_store.set_playlists([MediaItem("/p/1", "Jazz", "playlist")]) + """No digits within DIAL_ENTRY_TIMEOUT from handset lift → off-hook warning.""" menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) - menu.on_handset_lifted(now=_T0) - menu.tick(now=10.0) - menu.on_digit(1, now=10.1) - # Tick past disambiguation timeout - menu.tick(now=10.1 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) - # Digit 1 navigated to playlist browse — not direct dial - assert menu.state != MenuState.DIRECT_DIAL + menu.on_handset_lifted(now=0.0) + menu.tick(now=DIAL_ENTRY_TIMEOUT + 1.0) + assert menu.state == MenuState.OFF_HOOK, f"Expected OFF_HOOK, got {menu.state}" + off_hook = [c for c in mock_audio.calls if c[0] == 'play_off_hook_tone'] + assert off_hook, "Expected off-hook tone to play" - def test_disambiguation_second_digit_enters_direct_dial( + def test_dial_entry_timeout_incomplete_direct_dial_triggers_off_hook( self, mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path): - """Second digit within timeout → DIRECT_DIAL mode entered.""" + """Partial number entered but not completed within DIAL_ENTRY_TIMEOUT → off-hook.""" menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) - menu.on_handset_lifted(now=_T0) - t = 10.0 - menu.on_digit(1, now=t) - # Second digit within timeout - menu.on_digit(2, now=t + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT * 0.5) - menu.tick(now=t + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT * 0.5 + 0.01) + menu.on_handset_lifted(now=0.0) + menu.on_digit(5, now=0.5) # enters DIRECT_DIAL assert menu.state == MenuState.DIRECT_DIAL + menu.tick(now=DIAL_ENTRY_TIMEOUT + 1.0) # measured from handset_up_time (0.0) + assert menu.state == MenuState.OFF_HOOK, f"Expected OFF_HOOK, got {menu.state}" - def test_disambiguation_0_and_9_literal_in_direct_dial( + def test_0_and_9_literal_in_direct_dial( self, mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path): - """In DIRECT_DIAL mode, 0 and 9 are accumulated as phone number digits.""" + """In DIRECT_DIAL mode, 0 and 9 are accumulated as phone number digits (not navigation).""" menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_handset_lifted(now=_T0) - t = 10.0 - # Enter direct dial - menu.on_digit(5, now=t) - menu.on_digit(5, now=t + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT * 0.3) - menu.tick(now=t + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT * 0.3 + 0.01) + menu.on_digit(5, now=0.5) # enters DIRECT_DIAL from IDLE_DIAL_TONE assert menu.state == MenuState.DIRECT_DIAL - # Now dial 0 and 9 — should be accumulated, not navigation - menu.on_digit(0, now=t + 1.0) - menu.on_digit(9, now=t + 1.1) - # Still in DIRECT_DIAL (only 4 digits so far, need 7) + menu.on_digit(0, now=1.0) + menu.on_digit(9, now=1.1) + # Still in DIRECT_DIAL (only 3 digits so far, need 7) assert menu.state == MenuState.DIRECT_DIAL def test_dtmf_plays_for_each_direct_dial_digit( self, mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path): - """In DIRECT_DIAL mode, audio.play_dtmf called for each digit.""" + """In DIRECT_DIAL mode, audio.play_dtmf called for each digit including the first.""" menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_handset_lifted(now=_T0) - t = 10.0 - # Enter direct dial with first two digits - menu.on_digit(5, now=t) - menu.on_digit(5, now=t + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT * 0.3) - menu.tick(now=t + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT * 0.3 + 0.01) - assert menu.state == MenuState.DIRECT_DIAL - # Dial a third digit - menu.on_digit(5, now=t + 1.0) + menu.on_digit(5, now=0.5) # first digit → DIRECT_DIAL + menu.on_digit(5, now=1.0) # second digit + menu.on_digit(5, now=1.5) # third digit dtmf_calls = [c for c in mock_audio.calls if c[0] == 'play_dtmf'] - # At least 3 DTMF tones played (one per digit) assert len(dtmf_calls) >= 3 + def test_digit_0_goes_back_one_level_from_operator_menu( + self, mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path): + """Digit 0 from inside the operator menu → re-delivers the top-level menu.""" + mock_media_store.set_playlists([MediaItem("/p/1", "Jazz Mix", "playlist")]) + mock_media_client.set_now_playing(PlaybackState(item=None, is_paused=False)) + menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) + menu.on_handset_lifted(now=_T0) + menu.on_digit(0, now=0.5) # enter operator menu + assert menu.state == MenuState.IDLE_MENU + menu.on_digit(0, now=1.0) # at root → re-delivers top-level + assert menu.state in (MenuState.IDLE_MENU, MenuState.PLAYING_MENU) + + def test_digit_0_goes_back_from_browse( + self, mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path): + """Digit 0 from inside a browse state → go back one level to IDLE_MENU.""" + mock_media_store.set_playlists([MediaItem("/p/1", "Jazz Mix", "playlist")]) + mock_media_client.set_now_playing(PlaybackState(item=None, is_paused=False)) + menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) + menu.on_handset_lifted(now=_T0) + menu.on_digit(0, now=0.5) # operator menu + menu.on_digit(1, now=1.0) # navigate to playlist browse + assert menu.state == MenuState.BROWSE_PLAYLISTS + menu.on_digit(0, now=2.0) # back one level + assert menu.state in (MenuState.IDLE_MENU, MenuState.PLAYING_MENU) + + def test_digit_9_is_not_navigation_in_browse( + self, mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path): + """Digit 9 in browse state is T9 narrowing, not navigation-back.""" + mock_media_store.set_playlists([MediaItem("/p/1", "Jazz Mix", "playlist")]) + mock_media_client.set_now_playing(PlaybackState(item=None, is_paused=False)) + menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) + menu.on_handset_lifted(now=_T0) + menu.on_digit(0, now=0.5) # operator menu + menu.on_digit(1, now=1.0) # navigate to playlist browse + assert menu.state == MenuState.BROWSE_PLAYLISTS + menu.on_digit(9, now=2.0) # T9 narrowing (no match → stays in browse) + assert menu.state == MenuState.BROWSE_PLAYLISTS, ( + f"Digit 9 should be T9 browse, not navigation; state: {menu.state!r}" + ) + + def test_digit_9_at_top_level_is_treated_as_menu_digit( + self, mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path): + """Digit 9 at IDLE_MENU is treated as a menu option digit (not navigation).""" + mock_media_store.set_playlists([MediaItem("/p/1", "Jazz", "playlist")]) + mock_media_client.set_now_playing(PlaybackState(item=None, is_paused=False)) + menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) + menu.on_handset_lifted(now=_T0) + menu.on_digit(0, now=0.5) # operator menu + menu.on_digit(9, now=1.0) # option 9 doesn't exist → not in service, stays at IDLE_MENU + assert menu.state == MenuState.IDLE_MENU + + def test_navigation_digit_dispatched_immediately_in_menu( + self, mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path): + """Navigation digit in IDLE_MENU fires immediately in on_digit, no tick needed.""" + mock_media_store.set_playlists([MediaItem("/p/1", "Jazz Mix", "playlist")]) + mock_media_client.set_now_playing(PlaybackState(item=None, is_paused=False)) + menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) + menu.on_handset_lifted(now=_T0) + menu.on_digit(0, now=0.5) # operator menu + assert menu.state == MenuState.IDLE_MENU + mock_tts.calls.clear() + menu.on_digit(1, now=1.0) # navigate to playlists — fires immediately + assert menu.state == MenuState.BROWSE_PLAYLISTS + # --------------------------------------------------------------------------- # §9.2 Idle state top-level menu @@ -217,10 +245,10 @@ def idle_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_er class TestIdleMenu: - def _advance_to_idle_menu(self, menu, now=10.0): - """Lift handset and advance past dial tone timeout.""" + def _advance_to_idle_menu(self, menu): + """Lift handset and dial 0 to enter the operator menu.""" menu.on_handset_lifted(now=_T0) - menu.tick(now=now) + menu.on_digit(0, now=0.5) def test_idle_menu_announces_options(self, idle_menu, mock_tts, mock_media_client): """After dial tone timeout → TTS plays SCRIPT_OPERATOR_OPENER then SCRIPT_GREETING.""" @@ -236,7 +264,7 @@ def test_operator_opener_spoken_once_per_session(self, idle_menu, mock_tts, mock mock_tts.calls.clear() # Trigger another menu prompt (e.g., back to top via digit 9) idle_menu.on_digit(9, now=15.0) - idle_menu.tick(now=15.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + idle_menu.tick(now=15.0 + 0.1) texts = tts_calls(mock_tts) assert not any(SCRIPT_OPERATOR_OPENER in t for t in texts), \ f"Opener was replayed: {texts}" @@ -258,7 +286,7 @@ def test_idle_menu_option_1_playlist(self, idle_menu, mock_tts, mock_media_store self._advance_to_idle_menu(idle_menu) mock_tts.calls.clear() idle_menu.on_digit(1, now=11.0) - idle_menu.tick(now=11.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + idle_menu.tick(now=11.0 + 0.1) texts = tts_calls(mock_tts) assert any("playlist" in t.lower() for t in texts), f"playlist prompt not found: {texts}" @@ -267,7 +295,7 @@ def test_idle_menu_option_2_artist(self, idle_menu, mock_tts): self._advance_to_idle_menu(idle_menu) mock_tts.calls.clear() idle_menu.on_digit(2, now=11.0) - idle_menu.tick(now=11.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + idle_menu.tick(now=11.0 + 0.1) texts = tts_calls(mock_tts) assert any("artist" in t.lower() for t in texts), f"artist prompt not found: {texts}" @@ -276,7 +304,7 @@ def test_idle_menu_option_3_genre(self, idle_menu, mock_tts): self._advance_to_idle_menu(idle_menu) mock_tts.calls.clear() idle_menu.on_digit(3, now=11.0) - idle_menu.tick(now=11.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + idle_menu.tick(now=11.0 + 0.1) texts = tts_calls(mock_tts) assert any("genre" in t.lower() for t in texts), f"genre prompt not found: {texts}" @@ -285,7 +313,7 @@ def test_idle_menu_option_4_shuffle(self, idle_menu, mock_media_client): self._advance_to_idle_menu(idle_menu) mock_media_client.calls.clear() idle_menu.on_digit(4, now=11.0) - idle_menu.tick(now=11.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + idle_menu.tick(now=11.0 + 0.1) assert any(c[0] == 'shuffle_all' for c in mock_media_client.calls) def test_idle_menu_shuffle_speaks_connecting_announcement(self, idle_menu, mock_tts, mock_media_client): @@ -293,7 +321,7 @@ def test_idle_menu_shuffle_speaks_connecting_announcement(self, idle_menu, mock_ self._advance_to_idle_menu(idle_menu) mock_tts.calls.clear() idle_menu.on_digit(4, now=11.0) - idle_menu.tick(now=11.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + idle_menu.tick(now=11.0 + 0.1) texts = tts_calls(mock_tts) assert len(texts) > 0, f"Expected a TTS announcement after shuffle, got none" full_text = " ".join(texts).lower() @@ -307,7 +335,7 @@ def test_idle_menu_shuffle_transitions_to_playing_menu(self, idle_menu, mock_med """Digit 4 (shuffle) → state transitions to PLAYING_MENU.""" self._advance_to_idle_menu(idle_menu) idle_menu.on_digit(4, now=11.0) - idle_menu.tick(now=11.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + idle_menu.tick(now=11.0 + 0.1) assert idle_menu.state == MenuState.PLAYING_MENU, \ f"Expected PLAYING_MENU after shuffle, got {idle_menu.state}" @@ -315,7 +343,7 @@ def test_idle_menu_shuffle_hangup_leaves_music_playing(self, idle_menu, mock_med """After shuffle, hanging up must not call plex_client.stop().""" self._advance_to_idle_menu(idle_menu) idle_menu.on_digit(4, now=11.0) - idle_menu.tick(now=11.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + idle_menu.tick(now=11.0 + 0.1) mock_media_client.calls.clear() idle_menu.on_handset_on_cradle() media_stop_calls = [c for c in mock_media_client.calls if c[0] == 'stop'] @@ -331,7 +359,7 @@ def test_idle_menu_omits_empty_category(self, mock_audio, mock_tts, mock_media_c mock_media_client.set_now_playing(PlaybackState(item=None, is_paused=False)) menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_handset_lifted(now=_T0) - menu.tick(now=10.0) + menu.on_digit(0, now=0.5) texts = tts_calls(mock_tts) full_text = " ".join(texts).lower() assert "playlist" not in full_text or "artist" in full_text @@ -360,36 +388,29 @@ def test_idle_menu_invalid_digit(self, idle_menu, mock_tts): self._advance_to_idle_menu(idle_menu) mock_tts.calls.clear() idle_menu.on_digit(8, now=11.0) # 8 is not offered in idle menu - idle_menu.tick(now=11.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + idle_menu.tick(now=11.0 + 0.1) texts = tts_calls(mock_tts) assert any(SCRIPT_NOT_IN_SERVICE in t for t in texts), f"not-in-service not found: {texts}" - def test_now_playing_called_once_during_dial_tone( + def test_now_playing_not_called_during_dial_tone_ticks( self, mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path): - """now_playing() called exactly once at handset lift, not once per tick. + """now_playing() must NOT be called during IDLE_DIAL_TONE tick() polling. - Regression test: previously _check_dial_tone_timeout() called now_playing() - on every tick (200 Hz), flooding MPD with connect/disconnect cycles. + Regression guard: tick() must not query the media server (200 Hz × TCP = flood). + now_playing() is only called when the user dials 0 to request the operator menu. """ menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_handset_lifted(now=_T0) - # Count now_playing calls after lift but before the timeout fires - calls_before = [c for c in mock_media_client.calls if c[0] == 'now_playing'] mock_media_client.calls.clear() - # Simulate ~10 ticks within the dial tone window (not yet past the timeout) for i in range(10): menu.tick(now=_T0 + 0.05 * i) during_tone = [c for c in mock_media_client.calls if c[0] == 'now_playing'] assert len(during_tone) == 0, ( - f"now_playing() called {len(during_tone)} times during dial tone ticks " - f"(should be 0 — state was captured at lift)" - ) - # The one call at lift is accounted for separately - assert len(calls_before) == 1, ( - f"Expected exactly 1 now_playing() call at handset lift, got {len(calls_before)}" + f"now_playing() called {len(during_tone)} times during IDLE_DIAL_TONE ticks " + f"(should be 0 — called only when user dials 0)" ) def test_idle_menu_plex_failure_at_load(self, mock_audio, mock_tts, mock_media_client, @@ -409,7 +430,7 @@ def refresh(self): raise OSError("Plex down") menu = make_menu(mock_audio, mock_tts, mock_media_client, FailingStore(), mock_error_queue, tmp_path) menu.on_handset_lifted(now=_T0) - menu.tick(now=10.0) + menu.on_digit(0, now=0.5) texts = tts_calls(mock_tts) assert any(SCRIPT_MEDIA_FAILURE in t for t in texts), f"plex failure not found: {texts}" assert any(SCRIPT_RETRY_PROMPT in t for t in texts), f"retry prompt not found: {texts}" @@ -433,7 +454,7 @@ def refresh(self): raise OSError("Plex down") failing = FailingStore() menu = make_menu(mock_audio, mock_tts, mock_media_client, failing, mock_error_queue, tmp_path) menu.on_handset_lifted(now=_T0) - menu.tick(now=10.0) # triggers _deliver_idle_menu → fails → failure_mode = "media" + menu.on_digit(0, now=0.5) # dial 0 → operator path → triggers _deliver_idle_menu → fails assert menu._failure_mode == "media" # Now swap in a mock store that returns partial success on refresh @@ -445,7 +466,7 @@ def refresh(self): raise OSError("Plex down") mock_tts.calls.clear() menu.on_digit(1, now=11.0) - menu.tick(now=11.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=11.0 + 0.1) assert menu._failure_mode is None, f"failure_mode should be None, got {menu._failure_mode}" # Menu should have been delivered — TTS should speak menu options @@ -474,7 +495,7 @@ def refresh(self): raise OSError("Plex down") failing = FailingStore() menu = make_menu(mock_audio, mock_tts, mock_media_client, failing, mock_error_queue, tmp_path) menu.on_handset_lifted(now=_T0) - menu.tick(now=10.0) + menu.on_digit(0, now=0.5) assert menu._failure_mode == "media" # swap in a store whose refresh returns all errors @@ -483,7 +504,7 @@ def refresh(self): raise OSError("Plex down") mock_tts.calls.clear() menu.on_digit(1, now=11.0) - menu.tick(now=11.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=11.0 + 0.1) assert menu._failure_mode == "media", \ f"failure_mode should remain 'media', got {menu._failure_mode}" @@ -511,7 +532,7 @@ def refresh(self): raise OSError("Plex down") failing = FailingStore() menu = make_menu(mock_audio, mock_tts, mock_media_client, failing, mock_error_queue, tmp_path) menu.on_handset_lifted(now=_T0) - menu.tick(now=10.0) + menu.on_digit(0, now=0.5) assert menu._failure_mode == "media" # swap in a store that succeeds on refresh @@ -523,7 +544,7 @@ def refresh(self): raise OSError("Plex down") mock_tts.calls.clear() menu.on_digit(1, now=11.0) - menu.tick(now=11.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=11.0 + 0.1) assert menu._failure_mode is None, \ f"failure_mode should be None after complete success, got {menu._failure_mode}" @@ -540,7 +561,7 @@ def test_idle_menu_no_content_plays_off_hook_tone(self, mock_audio, mock_tts, mo mock_media_client.set_now_playing(PlaybackState(item=None, is_paused=False)) menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_handset_lifted(now=_T0) - menu.tick(now=10.0) + menu.on_digit(0, now=0.5) texts = tts_calls(mock_tts) assert any(SCRIPT_NO_CONTENT in t for t in texts), f"no_content not found: {texts}" off_hook = [c for c in mock_audio.calls if c[0] == 'play_off_hook_tone'] @@ -555,31 +576,27 @@ def test_off_hook_tone_stops_on_hangup(self, mock_audio, mock_tts, mock_media_cl mock_media_client.set_now_playing(PlaybackState(item=None, is_paused=False)) menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_handset_lifted(now=_T0) - menu.tick(now=10.0) + menu.on_digit(0, now=0.5) mock_audio.calls.clear() menu.on_handset_on_cradle() stop_calls = [c for c in mock_audio.calls if c[0] == 'stop'] assert len(stop_calls) >= 1 def test_inactivity_timeout_triggers_off_hook_tone(self, idle_menu, mock_audio): - """No digit for INACTIVITY_TIMEOUT → off-hook warning tone plays.""" + """No digit for INACTIVITY_TIMEOUT in operator menu → off-hook warning tone.""" idle_menu.on_handset_lifted(now=_T0) - # Advance past dial tone to deliver menu (t=10) - idle_menu.tick(now=10.0) - # Now advance past inactivity timeout from menu delivery (t = 10 + INACTIVITY_TIMEOUT + margin) - idle_menu.tick(now=10.0 + INACTIVITY_TIMEOUT + 5.0) + idle_menu.on_digit(0, now=0.5) # enter operator menu (last_activity_time = 0.5) + idle_menu.tick(now=0.5 + INACTIVITY_TIMEOUT + 5.0) off_hook = [c for c in mock_audio.calls if c[0] == 'play_off_hook_tone'] assert len(off_hook) >= 1 def test_inactivity_timeout_reset_on_digit(self, idle_menu, mock_audio): """Digit before inactivity timeout → timer resets, off-hook not triggered.""" idle_menu.on_handset_lifted(now=_T0) - idle_menu.tick(now=10.0) # advance to idle menu state - # Digit resets timer - idle_menu.on_digit(9, now=10.1) - idle_menu.tick(now=10.1 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + idle_menu.on_digit(0, now=0.5) # enter operator menu (last_activity_time = 0.5) + idle_menu.on_digit(9, now=10.0) # digit resets timer (last_activity_time = 10.0) # Not yet past inactivity timeout since last digit - idle_menu.tick(now=10.1 + INACTIVITY_TIMEOUT * 0.5) + idle_menu.tick(now=10.0 + INACTIVITY_TIMEOUT * 0.5) off_hook = [c for c in mock_audio.calls if c[0] == 'play_off_hook_tone'] assert len(off_hook) == 0 @@ -604,9 +621,9 @@ def playing_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock class TestPlayingMenu: - def _advance_to_playing_menu(self, menu, now=10.0): + def _advance_to_playing_menu(self, menu): menu.on_handset_lifted(now=_T0) - menu.tick(now=now) + menu.on_digit(0, now=0.5) def test_playing_menu_announces_options(self, playing_menu, mock_tts, playing_item): """Handset lifted while playing → TTS plays SCRIPT_OPERATOR_OPENER + SCRIPT_PLAYING_GREETING.""" @@ -620,7 +637,7 @@ def test_playing_menu_option_1_pause(self, playing_menu, mock_media_client): self._advance_to_playing_menu(playing_menu) mock_media_client.calls.clear() playing_menu.on_digit(1, now=11.0) - playing_menu.tick(now=11.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + playing_menu.tick(now=11.0 + 0.1) assert any(c[0] == 'pause' for c in mock_media_client.calls) def test_playing_menu_option_1_unpause(self, playing_menu, mock_media_client, playing_item): @@ -629,7 +646,7 @@ def test_playing_menu_option_1_unpause(self, playing_menu, mock_media_client, pl self._advance_to_playing_menu(playing_menu) mock_media_client.calls.clear() playing_menu.on_digit(1, now=11.0) - playing_menu.tick(now=11.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + playing_menu.tick(now=11.0 + 0.1) assert any(c[0] == 'unpause' for c in mock_media_client.calls) def test_playing_menu_pause_label_when_playing(self, playing_menu, mock_tts): @@ -650,7 +667,7 @@ def test_playing_menu_option_2_skip(self, playing_menu, mock_media_client): self._advance_to_playing_menu(playing_menu) mock_media_client.calls.clear() playing_menu.on_digit(2, now=11.0) - playing_menu.tick(now=11.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + playing_menu.tick(now=11.0 + 0.1) assert any(c[0] == 'skip' for c in mock_media_client.calls) def test_playing_menu_skip_not_offered_on_last_track(self, playing_menu, mock_tts, mock_media_client, playing_item): @@ -668,7 +685,7 @@ def test_playing_menu_option_3_end_call(self, playing_menu, mock_media_client): self._advance_to_playing_menu(playing_menu) mock_media_client.calls.clear() playing_menu.on_digit(3, now=11.0) - playing_menu.tick(now=11.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + playing_menu.tick(now=11.0 + 0.1) assert any(c[0] == 'stop' for c in mock_media_client.calls) assert playing_menu.state == MenuState.IDLE_MENU @@ -676,7 +693,7 @@ def test_playing_menu_option_0_re_delivers_playing_menu(self, playing_menu): """Digit 0 from PLAYING_MENU (no nav history) → re-delivers PLAYING_MENU (top-level back).""" self._advance_to_playing_menu(playing_menu) playing_menu.on_digit(0, now=11.0) - playing_menu.tick(now=11.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + playing_menu.tick(now=11.0 + 0.1) assert playing_menu.state == MenuState.PLAYING_MENU def test_playing_menu_now_playing_idle_at_speak_time( @@ -687,7 +704,7 @@ def test_playing_menu_now_playing_idle_at_speak_time( mock_media_client.set_now_playing(PlaybackState(item=None, is_paused=False)) menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_handset_lifted(now=_T0) - menu.tick(now=10.0) + menu.on_digit(0, now=0.5) texts = tts_calls(mock_tts) # Should get idle prompt, not playing prompt assert any(SCRIPT_GREETING in t for t in texts), f"idle greeting not found: {texts}" @@ -710,7 +727,7 @@ def test_idle_menu_after_stop_skips_dial_tone( mock_audio.calls.clear() # Stop music (digit 3) playing_menu.on_digit(3, now=11.0) - playing_menu.tick(now=11.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + playing_menu.tick(now=11.0 + 0.1) # SCRIPT_OPERATOR_OPENER should NOT be replayed texts = tts_calls(mock_tts) assert not any(SCRIPT_OPERATOR_OPENER in t for t in texts), \ @@ -748,11 +765,9 @@ def _make_browse_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_media_client.set_now_playing(PlaybackState(item=None, is_paused=False)) menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_handset_lifted(now=_T0) - menu.tick(now=10.0) # deliver idle menu + menu.on_digit(0, now=0.5) # enter operator menu mock_tts.calls.clear() - # Navigate to browse - menu.on_digit(digit, now=11.0) - menu.tick(now=11.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.on_digit(digit, now=1.0) # navigate to browse category mock_tts.calls.clear() return menu @@ -770,7 +785,7 @@ def test_browse_t9_digit_1_maps_to_ABC(self, mock_audio, mock_tts, mock_media_cl menu = _make_browse_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path, items) menu.on_digit(1, now=12.0) - menu.tick(now=12.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=12.0 + 0.1) texts = tts_calls(mock_tts) full = " ".join(texts) assert "Ambient Jazz" in full or "Blues Classic" in full @@ -799,7 +814,7 @@ def test_browse_t9_number_literal_match(self, mock_audio, mock_tts, mock_media_c menu = _make_browse_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path, items) menu.on_digit(3, now=12.0) - menu.tick(now=12.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=12.0 + 0.1) texts = tts_calls(mock_tts) full = " ".join(texts) assert "30 Seconds to Mars" in full @@ -814,7 +829,7 @@ def test_browse_t9_special_chars_under_9(self, mock_audio, mock_tts, mock_media_ menu = _make_browse_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path, items) menu.on_digit(9, now=12.0) - menu.tick(now=12.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=12.0 + 0.1) texts = tts_calls(mock_tts) assert "!Mix" in " ".join(texts) @@ -825,7 +840,7 @@ def test_browse_article_stripping_the(self, mock_audio, mock_tts, mock_media_cli menu = _make_browse_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path, items, category="artist") menu.on_digit(1, now=12.0) # B → 1 - menu.tick(now=12.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=12.0 + 0.1) texts = tts_calls(mock_tts) assert "The Beatles" in " ".join(texts) @@ -836,7 +851,7 @@ def test_browse_article_stripping_a(self, mock_audio, mock_tts, mock_media_clien menu = _make_browse_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path, items, category="artist") menu.on_digit(7, now=12.0) # T → 7 - menu.tick(now=12.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=12.0 + 0.1) texts = tts_calls(mock_tts) assert "A Tribe Called Quest" in " ".join(texts) @@ -847,7 +862,7 @@ def test_browse_article_stripping_an(self, mock_audio, mock_tts, mock_media_clie menu = _make_browse_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path, items, category="artist") menu.on_digit(1, now=12.0) # A → 1 - menu.tick(now=12.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=12.0 + 0.1) texts = tts_calls(mock_tts) assert "An Artist" in " ".join(texts) @@ -858,7 +873,7 @@ def test_browse_article_full_name_spoken(self, mock_audio, mock_tts, mock_media_ menu = _make_browse_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path, items, category="artist") menu.on_digit(6, now=12.0) # R → 6 - menu.tick(now=12.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=12.0 + 0.1) texts = tts_calls(mock_tts) assert "The Rolling Stones" in " ".join(texts) @@ -869,7 +884,7 @@ def test_browse_t9_case_insensitive(self, mock_audio, mock_tts, mock_media_clien menu = _make_browse_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path, items) menu.on_digit(1, now=12.0) # B → 1 - menu.tick(now=12.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=12.0 + 0.1) texts = tts_calls(mock_tts) assert "beatles mix" in " ".join(texts) @@ -880,7 +895,7 @@ def test_browse_exactly_8_results_listed(self, mock_audio, mock_tts, mock_media_ menu = _make_browse_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path, items) menu.on_digit(1, now=12.0) # All start with 'A' → digit 1 - menu.tick(now=12.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=12.0 + 0.1) texts = tts_calls(mock_tts) assert any(SCRIPT_BROWSE_LIST_INTRO in t for t in texts) # Should not ask for next letter @@ -896,7 +911,7 @@ def test_browse_8_or_fewer_results_listed(self, mock_audio, mock_tts, mock_media menu = _make_browse_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path, items) menu.on_digit(1, now=12.0) - menu.tick(now=12.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=12.0 + 0.1) texts = tts_calls(mock_tts) full = " ".join(texts) assert SCRIPT_BROWSE_LIST_INTRO in full @@ -910,7 +925,7 @@ def test_browse_more_than_8_prompts_next_letter(self, mock_audio, mock_tts, mock menu = _make_browse_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path, items) menu.on_digit(1, now=12.0) # All start with 'A' → digit 1 - menu.tick(now=12.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=12.0 + 0.1) texts = tts_calls(mock_tts) assert any(SCRIPT_BROWSE_PROMPT_NEXT_LETTER in t for t in texts) @@ -928,15 +943,15 @@ def test_browse_narrow_until_8_or_fewer(self, mock_audio, mock_tts, mock_media_c mock_error_queue, tmp_path, items) # First digit: A → digit 1 (all 10 → next letter prompt) menu.on_digit(1, now=12.0) - menu.tick(now=12.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=12.0 + 0.1) mock_tts.calls.clear() # Second digit: M → digit 5 (all 10 still match → next letter prompt) menu.on_digit(5, now=13.0) - menu.tick(now=13.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=13.0 + 0.1) mock_tts.calls.clear() # Third digit: B → digit 1 (ABC), matches 'Ambi*' (5 items ≤ 8 → list) menu.on_digit(1, now=14.0) - menu.tick(now=14.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=14.0 + 0.1) texts = tts_calls(mock_tts) # Should show list, not ask for next letter assert any(SCRIPT_BROWSE_LIST_INTRO in t for t in texts) @@ -948,7 +963,7 @@ def test_browse_single_result_auto_selects(self, mock_audio, mock_tts, mock_medi menu = _make_browse_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path, items) menu.on_digit(1, now=12.0) - menu.tick(now=12.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=12.0 + 0.1) texts = tts_calls(mock_tts) assert any(SCRIPT_BROWSE_AUTO_SELECT in t for t in texts) @@ -959,7 +974,7 @@ def test_browse_no_results_says_no_match(self, mock_audio, mock_tts, mock_media_ menu = _make_browse_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path, items) menu.on_digit(1, now=12.0) # digit 1 = ABC, no match for 'Jazz' - menu.tick(now=12.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=12.0 + 0.1) texts = tts_calls(mock_tts) assert any(SCRIPT_NOT_IN_SERVICE in t for t in texts) @@ -995,15 +1010,15 @@ def _navigate_to_artist(self, mock_audio, mock_tts, mock_media_client, mock_medi menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_handset_lifted(now=_T0) - menu.tick(now=10.0) + menu.on_digit(0, now=0.5) # dial 0 → operator menu # Digit 2 → artist browse - menu.on_digit(2, now=11.0) - menu.tick(now=11.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.on_digit(2, now=1.0) + menu.tick(now=11.0 + 0.1) mock_tts.calls.clear() # T → digit 7 → "The Beatles" found (after stripping "The " → "Beatles" → B → digit 1) # Actually "The Beatles" strips to "Beatles" → B → digit 1 menu.on_digit(1, now=12.0) - menu.tick(now=12.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=12.0 + 0.1) mock_tts.calls.clear() return menu @@ -1014,7 +1029,7 @@ def test_artist_submenu_option_1_shuffle_artist(self, mock_audio, mock_tts, mock mock_error_queue, tmp_path, artist) mock_media_client.calls.clear() menu.on_digit(1, now=13.0) - menu.tick(now=13.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=13.0 + 0.1) # Should have called play or shuffle for the artist assert any(c[0] in ('play', 'shuffle_all') for c in mock_media_client.calls) @@ -1024,7 +1039,7 @@ def test_artist_submenu_option_2_choose_album(self, mock_audio, mock_tts, mock_m menu = self._navigate_to_artist(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path, artist, albums=albums) menu.on_digit(2, now=13.0) - menu.tick(now=13.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=13.0 + 0.1) texts = tts_calls(mock_tts) assert any("album" in t.lower() for t in texts) assert menu.state == MenuState.BROWSE_ALBUMS @@ -1035,7 +1050,7 @@ def test_artist_submenu_album_option_omitted_when_no_albums( menu = self._navigate_to_artist(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path, artist, albums=[]) menu.on_digit(2, now=13.0) - menu.tick(now=13.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=13.0 + 0.1) texts = tts_calls(mock_tts) assert any(SCRIPT_NOT_IN_SERVICE in t for t in texts) @@ -1055,11 +1070,11 @@ def test_artist_album_t9_browsing(self, mock_audio, mock_tts, mock_media_client, menu = self._navigate_to_artist(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path, artist, albums=albums) menu.on_digit(2, now=13.0) - menu.tick(now=13.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=13.0 + 0.1) mock_tts.calls.clear() # Digit 1 → A (Abbey Road starts with A) menu.on_digit(1, now=14.0) - menu.tick(now=14.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=14.0 + 0.1) texts = tts_calls(mock_tts) assert "Abbey Road" in " ".join(texts) @@ -1069,12 +1084,12 @@ def test_artist_album_selection_plays_album(self, mock_audio, mock_tts, mock_med menu = self._navigate_to_artist(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path, artist, albums=albums) menu.on_digit(2, now=13.0) - menu.tick(now=13.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=13.0 + 0.1) mock_tts.calls.clear() mock_media_client.calls.clear() # Digit 1 → Abbey Road (A) menu.on_digit(1, now=14.0) - menu.tick(now=14.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=14.0 + 0.1) # Auto-selected (only 1 match) — play should be called play_calls = [c for c in mock_media_client.calls if c[0] == 'play'] assert len(play_calls) >= 1 @@ -1087,12 +1102,11 @@ def test_artist_album_selection_plays_album(self, mock_audio, mock_tts, mock_med def _dial_number(menu, number_str, start_time=1.0): """Helper: dial a full phone number into direct-dial mode.""" digits = [int(c) for c in number_str] - # First two digits trigger DIRECT_DIAL mode + # First digit triggers DIRECT_DIAL mode from IDLE_DIAL_TONE menu.on_digit(digits[0], now=start_time) - menu.on_digit(digits[1], now=start_time + 0.05) # Remaining digits - t = start_time + 0.1 - for d in digits[2:]: + t = start_time + 0.05 + for d in digits[1:]: menu.on_digit(d, now=t) t += 0.05 return t @@ -1110,7 +1124,6 @@ def _menu_with_handset_up(self, mock_audio, mock_tts, mock_media_client, mock_me mock_media_store.set_artists([MediaItem("/ar/1", "Beatles", "artist")]) mock_media_store.set_genres([]) menu.on_handset_lifted(now=0.0) - menu.tick(now=10.0) # past dial tone timeout → idle menu mock_tts.calls.clear() mock_media_client.calls.clear() return menu @@ -1203,7 +1216,7 @@ def test_assistant_message_option_states_count( mock_tts.calls.clear() # Dial 1 to hear errors menu.on_digit(1, now=20.0) - menu.tick(now=20.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=20.0 + 0.1) texts = " ".join(tts_calls(mock_tts)) # Should mention message count assert "1" in texts or "one" in texts.lower() @@ -1223,7 +1236,7 @@ def test_assistant_reads_first_page_then_asks( _dial_number(menu, ASSISTANT_NUMBER, start_time=11.0) mock_tts.calls.clear() menu.on_digit(1, now=20.0) - menu.tick(now=20.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=20.0 + 0.1) texts = " ".join(tts_calls(mock_tts)) # Should ask to continue assert "continue" in texts.lower() or "go on" in texts.lower() or "dial one" in texts.lower() @@ -1241,7 +1254,7 @@ def test_assistant_end_of_messages( _dial_number(menu, ASSISTANT_NUMBER, start_time=11.0) mock_tts.calls.clear() menu.on_digit(1, now=20.0) - menu.tick(now=20.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=20.0 + 0.1) texts = " ".join(tts_calls(mock_tts)) assert SCRIPT_ASSISTANT_END_OF_MESSAGES in texts or "last" in texts.lower() @@ -1259,11 +1272,11 @@ def test_assistant_continue_reads_next_page( _dial_number(menu, ASSISTANT_NUMBER, start_time=11.0) # Select errors (dial 1) menu.on_digit(1, now=20.0) - menu.tick(now=20.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=20.0 + 0.1) mock_tts.calls.clear() # Continue (dial 1) menu.on_digit(1, now=21.0) - menu.tick(now=21.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=21.0 + 0.1) texts = " ".join(tts_calls(mock_tts)) # Should have read more messages assert len(texts) > 0 @@ -1280,7 +1293,7 @@ def test_assistant_always_offers_navigation( ] _dial_number(menu, ASSISTANT_NUMBER, start_time=11.0) menu.on_digit(1, now=20.0) - menu.tick(now=20.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=20.0 + 0.1) texts = " ".join(tts_calls(mock_tts)) assert SCRIPT_ASSISTANT_NAVIGATION in texts or "dial" in texts.lower() @@ -1334,7 +1347,7 @@ def test_assistant_messages_not_marked_read( ] _dial_number(menu, ASSISTANT_NUMBER, start_time=11.0) menu.on_digit(1, now=20.0) - menu.tick(now=20.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=20.0 + 0.1) menu.on_handset_on_cradle() assert len(mock_error_queue.entries) == initial_count @@ -1362,7 +1375,7 @@ def test_assistant_refresh_calls_media_store_refresh( # With no errors, options are: [refresh=1, return=0] or similar # We just try digit 1 and check if refresh was called menu.on_digit(1, now=20.0) - menu.tick(now=20.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=20.0 + 0.1) refresh_calls = [c for c in mock_media_store.calls if c[0] == 'refresh'] assert len(refresh_calls) >= 1 @@ -1376,7 +1389,7 @@ def test_assistant_refresh_success_message( _dial_number(menu, ASSISTANT_NUMBER, start_time=11.0) mock_tts.calls.clear() menu.on_digit(1, now=20.0) - menu.tick(now=20.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=20.0 + 0.1) texts = " ".join(tts_calls(mock_tts)) assert SCRIPT_ASSISTANT_REFRESH_SUCCESS in texts or "updated" in texts.lower() @@ -1394,7 +1407,7 @@ def _fail_refresh(): _dial_number(menu, ASSISTANT_NUMBER, start_time=11.0) mock_tts.calls.clear() menu.on_digit(1, now=20.0) - menu.tick(now=20.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=20.0 + 0.1) texts = " ".join(tts_calls(mock_tts)) assert SCRIPT_ASSISTANT_REFRESH_FAILURE in texts or "trouble" in texts.lower() @@ -1407,7 +1420,7 @@ def test_assistant_refresh_offers_return_to_menu( mock_error_queue.entries.clear() _dial_number(menu, ASSISTANT_NUMBER, start_time=11.0) menu.on_digit(1, now=20.0) - menu.tick(now=20.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=20.0 + 0.1) texts = " ".join(tts_calls(mock_tts)) assert SCRIPT_ASSISTANT_NAVIGATION in texts or "menu" in texts.lower() or "switchboard" in texts.lower() @@ -1426,7 +1439,7 @@ def test_assistant_pagination_says_first_then_next( # Select errors (digit 1) mock_tts.calls.clear() menu.on_digit(1, now=20.0) - menu.tick(now=20.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=20.0 + 0.1) # Capture page 1 announcement page1_calls = list(tts_calls(mock_tts)) assert any("first" in t for t in page1_calls), \ @@ -1436,7 +1449,7 @@ def test_assistant_pagination_says_first_then_next( mock_tts.calls.clear() # Continue to page 2 (digit 1) menu.on_digit(1, now=21.0) - menu.tick(now=21.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=21.0 + 0.1) # Capture page 2 announcement page2_calls = list(tts_calls(mock_tts)) assert any("next" in t for t in page2_calls), \ @@ -1452,14 +1465,13 @@ class TestFinalSelection: def _menu_at_idle(self, mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path): - """Create a menu at IDLE_MENU state.""" + """Create a menu at IDLE_DIAL_TONE state (handset lifted, ready to dial).""" menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) mock_media_store.set_playlists([MediaItem("/pl/1", "Jazz", "playlist")]) mock_media_store.set_artists([]) mock_media_store.set_genres([]) menu.on_handset_lifted(now=0.0) - menu.tick(now=10.0) return menu def test_final_selection_speaks_connecting( @@ -1477,9 +1489,8 @@ def test_final_selection_speaks_connecting( mock_media_store.set_artists([]) mock_media_store.set_genres([]) menu.on_handset_lifted(now=0.0) - menu.tick(now=10.0) mock_tts.calls.clear() - _dial_number(menu, number, start_time=11.0) + _dial_number(menu, number, start_time=1.0) texts = " ".join(tts_calls(mock_tts)) assert "Jazz" in texts or "connecting" in texts.lower() @@ -1496,9 +1507,8 @@ def test_final_selection_phone_number_spoken_digit_by_digit( mock_media_store.set_artists([]) mock_media_store.set_genres([]) menu.on_handset_lifted(now=0.0) - menu.tick(now=10.0) mock_tts.calls.clear() - _dial_number(menu, number, start_time=11.0) + _dial_number(menu, number, start_time=1.0) texts = " ".join(tts_calls(mock_tts)) # Each digit should appear as a word or numeral in TTS output digit_words = {'0': 'zero', '1': 'one', '2': 'two', '3': 'three', '4': 'four', @@ -1520,9 +1530,8 @@ def test_final_selection_starts_playback( mock_media_store.set_artists([]) mock_media_store.set_genres([]) menu.on_handset_lifted(now=0.0) - menu.tick(now=10.0) mock_media_client.calls.clear() - _dial_number(menu, number, start_time=11.0) + _dial_number(menu, number, start_time=1.0) play_calls = [c for c in mock_media_client.calls if c[0] == 'play'] assert len(play_calls) >= 1 assert play_calls[0][1] == "/pl/1" @@ -1541,10 +1550,10 @@ def _make_genre_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_media_client.set_now_playing(PlaybackState(item=None, is_paused=False)) menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_handset_lifted(now=0.0) - menu.tick(now=10.0) # past dial tone timeout → IDLE_MENU + menu.on_digit(0, now=0.5) # dial 0 → operator menu # Digit 1 = genres (only category available) - menu.on_digit(1, now=11.0) - menu.tick(now=11.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.on_digit(1, now=1.0) + menu.tick(now=1.0 + 0.1) assert menu.state == MenuState.BROWSE_GENRES, f"Expected BROWSE_GENRES, got {menu.state}" return menu @@ -1564,7 +1573,7 @@ def test_selecting_genre_calls_get_tracks_for_genre( # Only one genre → auto-selected after dialling its first letter (J → digit 4) mock_media_client.calls.clear() menu.on_digit(4, now=12.0) # J is in group 4 (JKL) - menu.tick(now=12.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=12.0 + 0.1) get_tracks_calls = [c for c in mock_media_client.calls if c[0] == 'get_tracks_for_genre'] assert get_tracks_calls, f"get_tracks_for_genre not called; calls: {mock_media_client.calls}" @@ -1579,7 +1588,7 @@ def test_selecting_genre_calls_play_tracks_with_shuffle( ) mock_media_client.calls.clear() menu.on_digit(4, now=12.0) - menu.tick(now=12.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=12.0 + 0.1) play_tracks_calls = [c for c in mock_media_client.calls if c[0] == 'play_tracks'] assert play_tracks_calls, f"play_tracks not called; calls: {mock_media_client.calls}" assert play_tracks_calls[0][1] == ["101", "102"], \ @@ -1596,7 +1605,7 @@ def test_selecting_genre_transitions_to_playing_menu( mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path, genres ) menu.on_digit(4, now=12.0) - menu.tick(now=12.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=12.0 + 0.1) assert menu.state == MenuState.PLAYING_MENU, f"Expected PLAYING_MENU, got {menu.state}" def test_selecting_genre_does_not_call_play( @@ -1610,7 +1619,7 @@ def test_selecting_genre_does_not_call_play( ) mock_media_client.calls.clear() menu.on_digit(4, now=12.0) - menu.tick(now=12.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=12.0 + 0.1) play_calls = [c for c in mock_media_client.calls if c[0] == 'play'] assert not play_calls, f"play() should not be called for genre; calls: {mock_media_client.calls}" @@ -1625,7 +1634,7 @@ def test_empty_genre_speaks_not_in_service( ) mock_tts.calls.clear() menu.on_digit(4, now=12.0) - menu.tick(now=12.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=12.0 + 0.1) texts = tts_calls(mock_tts) assert any(SCRIPT_NOT_IN_SERVICE in t for t in texts), \ f"SCRIPT_NOT_IN_SERVICE not spoken; texts: {texts}" @@ -1641,7 +1650,7 @@ def test_empty_genre_does_not_call_play_tracks( ) mock_media_client.calls.clear() menu.on_digit(4, now=12.0) - menu.tick(now=12.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=12.0 + 0.1) play_tracks_calls = [c for c in mock_media_client.calls if c[0] == 'play_tracks'] assert not play_tracks_calls, f"play_tracks should not be called; calls: {mock_media_client.calls}" @@ -1655,7 +1664,7 @@ def test_empty_genre_returns_to_browse_state( mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path, genres ) menu.on_digit(4, now=12.0) - menu.tick(now=12.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=12.0 + 0.1) assert menu.state != MenuState.PLAYING_MENU, \ f"State should not be PLAYING_MENU after empty genre; got {menu.state}" @@ -1668,15 +1677,15 @@ def test_playlist_selection_unaffected_by_genre_changes( mock_media_client.set_now_playing(PlaybackState(item=None, is_paused=False)) menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_handset_lifted(now=0.0) - menu.tick(now=10.0) + menu.on_digit(0, now=0.5) # enter operator menu # Navigate to playlist browse - menu.on_digit(1, now=11.0) - menu.tick(now=11.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.on_digit(1, now=1.0) + menu.tick(now=1.0 + 0.1) assert menu.state == MenuState.BROWSE_PLAYLISTS mock_media_client.calls.clear() # Dial J (digit 4) → selects "Jazz Mix" - menu.on_digit(4, now=12.0) - menu.tick(now=12.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.on_digit(4, now=2.0) + menu.tick(now=12.0 + 0.1) play_calls = [c for c in mock_media_client.calls if c[0] == 'play'] assert play_calls, f"play() should be called for playlist; calls: {mock_media_client.calls}" assert play_calls[0][1] == "/pl/1" @@ -1703,10 +1712,10 @@ def _make_playlist_menu(self, mock_audio, mock_tts, mock_media_client, mock_medi menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_handset_lifted(now=0.0) - menu.tick(now=10.0) # past dial tone timeout → IDLE_MENU + menu.on_digit(0, now=0.5) # enter operator menu # Digit 1 → playlist browse (only category available) - menu.on_digit(1, now=11.0) - menu.tick(now=11.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.on_digit(1, now=1.0) + menu.tick(now=1.0 + 0.1) assert menu.state == MenuState.BROWSE_PLAYLISTS, \ f"Expected BROWSE_PLAYLISTS, got {menu.state}" mock_tts.calls.clear() @@ -1726,20 +1735,20 @@ def _make_album_menu(self, mock_audio, mock_tts, mock_media_client, mock_media_s menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_handset_lifted(now=0.0) - menu.tick(now=10.0) + menu.on_digit(0, now=0.5) # enter operator menu # Digit 1 → artist browse (only category available) - menu.on_digit(1, now=11.0) - menu.tick(now=11.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.on_digit(1, now=1.0) + menu.tick(now=1.0 + 0.1) assert menu.state == MenuState.BROWSE_ARTISTS, \ f"Expected BROWSE_ARTISTS, got {menu.state}" # Digit 1 → B (Beatles starts with B) - menu.on_digit(1, now=12.0) - menu.tick(now=12.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.on_digit(1, now=2.0) + menu.tick(now=2.0 + 0.1) assert menu.state == MenuState.ARTIST_SUBMENU, \ f"Expected ARTIST_SUBMENU, got {menu.state}" # Digit 2 → browse albums - menu.on_digit(2, now=13.0) - menu.tick(now=13.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.on_digit(2, now=3.0) + menu.tick(now=3.0 + 0.1) assert menu.state == MenuState.BROWSE_ALBUMS, \ f"Expected BROWSE_ALBUMS, got {menu.state}" mock_tts.calls.clear() @@ -1760,10 +1769,10 @@ def _make_genre_menu_f09(self, mock_audio, mock_tts, mock_media_client, mock_med menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_handset_lifted(now=0.0) - menu.tick(now=10.0) + menu.on_digit(0, now=0.5) # enter operator menu # Digit 1 → genre browse (only category available) - menu.on_digit(1, now=11.0) - menu.tick(now=11.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.on_digit(1, now=1.0) + menu.tick(now=1.0 + 0.1) assert menu.state == MenuState.BROWSE_GENRES, \ f"Expected BROWSE_GENRES, got {menu.state}" mock_tts.calls.clear() @@ -1782,15 +1791,15 @@ def _make_artist_menu(self, mock_audio, mock_tts, mock_media_client, mock_media_ menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_handset_lifted(now=0.0) - menu.tick(now=10.0) + menu.on_digit(0, now=0.5) # enter operator menu # Digit 1 → artist browse - menu.on_digit(1, now=11.0) - menu.tick(now=11.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.on_digit(1, now=1.0) + menu.tick(now=1.0 + 0.1) assert menu.state == MenuState.BROWSE_ARTISTS, \ f"Expected BROWSE_ARTISTS, got {menu.state}" # Digit 1 → B (Beatles) - menu.on_digit(1, now=12.0) - menu.tick(now=12.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.on_digit(1, now=2.0) + menu.tick(now=2.0 + 0.1) assert menu.state == MenuState.ARTIST_SUBMENU, \ f"Expected ARTIST_SUBMENU, got {menu.state}" mock_tts.calls.clear() @@ -1809,7 +1818,7 @@ def test_playlist_selection_speaks_connecting_template( mock_error_queue, tmp_path) # Dial J (digit 4) → selects "Jazz Mix" menu.on_digit(4, now=12.0) - menu.tick(now=12.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=12.0 + 0.1) texts = tts_calls(mock_tts) # "Please hold." is unique to SCRIPT_CONNECTING_TEMPLATE assert any("Please hold" in t for t in texts), \ @@ -1821,7 +1830,7 @@ def test_playlist_selection_speaks_item_name( menu = self._make_playlist_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_digit(4, now=12.0) - menu.tick(now=12.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=12.0 + 0.1) # Find the connecting template call specifically connecting_texts = [t for t in tts_calls(mock_tts) if "Please hold" in t] assert connecting_texts, \ @@ -1838,7 +1847,7 @@ def test_playlist_selection_speaks_digit_words( menu = self._make_playlist_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_digit(4, now=12.0) - menu.tick(now=12.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=12.0 + 0.1) # Find the connecting template call specifically connecting_texts = [t for t in tts_calls(mock_tts) if "Please hold" in t] assert connecting_texts, \ @@ -1855,7 +1864,7 @@ def test_playlist_selection_calls_play( menu = self._make_playlist_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_digit(4, now=12.0) - menu.tick(now=12.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=12.0 + 0.1) play_calls = [c for c in mock_media_client.calls if c[0] == 'play'] assert play_calls, f"Expected play() call, got: {mock_media_client.calls}" assert play_calls[0][1] == "/pl/1" @@ -1866,7 +1875,7 @@ def test_playlist_selection_transitions_to_playing_menu( menu = self._make_playlist_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_digit(4, now=12.0) - menu.tick(now=12.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=12.0 + 0.1) assert menu.state == MenuState.PLAYING_MENU, \ f"Expected PLAYING_MENU, got {menu.state}" @@ -1894,7 +1903,7 @@ def tracking_play(key): menu = self._make_playlist_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_digit(4, now=12.0) - menu.tick(now=12.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=12.0 + 0.1) # "Please hold" is unique to SCRIPT_CONNECTING_TEMPLATE speak_indices = [i for i, c in enumerate(call_order) if c[0] == 'speak' @@ -1916,7 +1925,7 @@ def test_album_selection_speaks_connecting_template( mock_error_queue, tmp_path) # Digit 1 → A (Abbey Road starts with A) → auto-selects menu.on_digit(1, now=14.0) - menu.tick(now=14.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=14.0 + 0.1) texts = tts_calls(mock_tts) # "Please hold." is unique to SCRIPT_CONNECTING_TEMPLATE assert any("Please hold" in t for t in texts), \ @@ -1928,7 +1937,7 @@ def test_album_selection_speaks_item_name( menu = self._make_album_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_digit(1, now=14.0) - menu.tick(now=14.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=14.0 + 0.1) # Find the connecting template call specifically connecting_texts = [t for t in tts_calls(mock_tts) if "Please hold" in t] assert connecting_texts, \ @@ -1942,7 +1951,7 @@ def test_album_selection_transitions_to_playing_menu( menu = self._make_album_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_digit(1, now=14.0) - menu.tick(now=14.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=14.0 + 0.1) assert menu.state == MenuState.PLAYING_MENU, \ f"Expected PLAYING_MENU, got {menu.state}" @@ -1957,7 +1966,7 @@ def test_genre_selection_speaks_connecting_template( mock_error_queue, tmp_path) # Dial J (digit 4) → selects "Jazz" menu.on_digit(4, now=12.0) - menu.tick(now=12.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=12.0 + 0.1) texts = tts_calls(mock_tts) # "Please hold." is unique to SCRIPT_CONNECTING_TEMPLATE assert any("Please hold" in t for t in texts), \ @@ -1969,7 +1978,7 @@ def test_genre_selection_speaks_item_name( menu = self._make_genre_menu_f09(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_digit(4, now=12.0) - menu.tick(now=12.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=12.0 + 0.1) # Find the connecting template call specifically connecting_texts = [t for t in tts_calls(mock_tts) if "Please hold" in t] assert connecting_texts, \ @@ -1983,7 +1992,7 @@ def test_genre_selection_transitions_to_playing_menu( menu = self._make_genre_menu_f09(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_digit(4, now=12.0) - menu.tick(now=12.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=12.0 + 0.1) assert menu.state == MenuState.PLAYING_MENU, \ f"Expected PLAYING_MENU, got {menu.state}" @@ -1997,7 +2006,7 @@ def test_artist_shuffle_speaks_connecting_template( menu = self._make_artist_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_digit(1, now=13.0) - menu.tick(now=13.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=13.0 + 0.1) texts = tts_calls(mock_tts) # "Please hold." is unique to SCRIPT_CONNECTING_TEMPLATE assert any("Please hold" in t for t in texts), \ @@ -2009,7 +2018,7 @@ def test_artist_shuffle_speaks_artist_name( menu = self._make_artist_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_digit(1, now=13.0) - menu.tick(now=13.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=13.0 + 0.1) # Find the connecting template call specifically connecting_texts = [t for t in tts_calls(mock_tts) if "Please hold" in t] assert connecting_texts, \ @@ -2023,7 +2032,7 @@ def test_artist_shuffle_calls_play( menu = self._make_artist_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_digit(1, now=13.0) - menu.tick(now=13.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=13.0 + 0.1) play_calls = [c for c in mock_media_client.calls if c[0] == 'play'] assert play_calls, f"Expected play() call, got: {mock_media_client.calls}" assert play_calls[0][1] == "/a/1" @@ -2034,7 +2043,7 @@ def test_artist_shuffle_transitions_to_playing_menu( menu = self._make_artist_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_digit(1, now=13.0) - menu.tick(now=13.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=13.0 + 0.1) assert menu.state == MenuState.PLAYING_MENU, \ f"Expected PLAYING_MENU, got {menu.state}" @@ -2048,7 +2057,7 @@ def test_artist_shuffle_phone_number_matches_phone_book( menu = self._make_artist_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_digit(1, now=13.0) - menu.tick(now=13.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=13.0 + 0.1) # The SCRIPT_CONNECTING_TEMPLATE call must contain digit words (phone number) connecting_texts = [t for t in tts_calls(mock_tts) if "Please hold" in t] assert connecting_texts, \ @@ -2080,15 +2089,15 @@ def _make_artist_submenu_with_albums(self, mock_audio, mock_tts, mock_media_clie menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_handset_lifted(now=0.0) - menu.tick(now=10.0) + menu.on_digit(0, now=0.5) # enter operator menu # Digit 1 -> artist browse - menu.on_digit(1, now=11.0) - menu.tick(now=11.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.on_digit(1, now=1.0) + menu.tick(now=1.0 + 0.1) assert menu.state == MenuState.BROWSE_ARTISTS, \ f"Expected BROWSE_ARTISTS, got {menu.state}" # Digit 1 -> B (Beatles) - menu.on_digit(1, now=12.0) - menu.tick(now=12.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.on_digit(1, now=2.0) + menu.tick(now=2.0 + 0.1) assert menu.state == MenuState.ARTIST_SUBMENU, \ f"Expected ARTIST_SUBMENU, got {menu.state}" mock_tts.calls.clear() @@ -2102,7 +2111,7 @@ def test_invalid_digit_speaks_not_in_service( mock_media_store, mock_error_queue, tmp_path) # Digit 5 is not a valid option in ARTIST_SUBMENU menu.on_digit(5, now=13.0) - menu.tick(now=13.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=13.0 + 0.1) texts = tts_calls(mock_tts) assert any(SCRIPT_NOT_IN_SERVICE in t for t in texts), \ f"SCRIPT_NOT_IN_SERVICE not spoken after invalid digit; texts: {texts}" @@ -2113,7 +2122,7 @@ def test_invalid_digit_re_delivers_artist_submenu( menu = self._make_artist_submenu_with_albums(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_digit(5, now=13.0) - menu.tick(now=13.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=13.0 + 0.1) texts = tts_calls(mock_tts) # The re-delivered submenu text must contain the artist name assert any("Beatles" in t for t in texts), \ @@ -2125,7 +2134,7 @@ def test_invalid_digit_re_delivered_text_includes_album_option( menu = self._make_artist_submenu_with_albums(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_digit(5, now=13.0) - menu.tick(now=13.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=13.0 + 0.1) texts = tts_calls(mock_tts) # SCRIPT_ARTIST_SUBMENU_ALBUMS_SUFFIX contains "album" assert any("album" in t.lower() for t in texts), \ @@ -2137,7 +2146,7 @@ def test_current_artist_preserved_after_invalid_digit( menu = self._make_artist_submenu_with_albums(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_digit(5, now=13.0) - menu.tick(now=13.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=13.0 + 0.1) # State must still be ARTIST_SUBMENU (current artist preserved) assert menu.state == MenuState.ARTIST_SUBMENU, \ f"Expected ARTIST_SUBMENU after invalid digit, got {menu.state}" @@ -2149,103 +2158,79 @@ def test_current_artist_still_usable_after_re_delivery( mock_media_store, mock_error_queue, tmp_path) # First, dial invalid digit menu.on_digit(5, now=13.0) - menu.tick(now=13.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=13.0 + 0.1) mock_tts.calls.clear() mock_media_client.calls.clear() # Now dial 1 -- should still play the artist menu.on_digit(1, now=14.0) - menu.tick(now=14.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=14.0 + 0.1) play_calls = [c for c in mock_media_client.calls if c[0] == 'play'] assert play_calls, f"Expected play() after re-delivery, got: {mock_media_client.calls}" # --------------------------------------------------------------------------- -# F-11 · Digit received before dial-tone menu is delivered +# F-11 · IDLE_DIAL_TONE routing: 0 → operator menu, non-zero → direct dial # --------------------------------------------------------------------------- -class TestDigitBeforeMenu: - """Digits dialed during IDLE_DIAL_TONE (before timeout fires the menu) - must not cause invalid state routing. The menu should be delivered first, - the dial tone stopped, and the queued digit dropped.""" +class TestIdleDialToneRouting: + """IDLE_DIAL_TONE is the entry point for all dialing. + Digit 0 → operator menu immediately. + Digit 1-9 → DIRECT_DIAL immediately (no disambiguation wait). + The dial tone is stopped as soon as any digit is received. + """ - def _make_menu_with_content(self, mock_audio, mock_tts, mock_media_client, - mock_media_store, mock_error_queue, tmp_path): - """Helper: build a Menu with one playlist available (idle state).""" + def test_nonzero_digit_enters_direct_dial_not_operator_menu( + self, mock_audio, mock_tts, mock_media_client, mock_media_store, + mock_error_queue, tmp_path): + """Non-zero digit from IDLE_DIAL_TONE → DIRECT_DIAL, not IDLE_MENU.""" mock_media_store.set_playlists([MediaItem("/p/1", "Jazz Mix", "playlist")]) mock_media_client.set_now_playing(PlaybackState(item=None, is_paused=False)) - return make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, + menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) - - def test_digit_during_idle_dial_tone_delivers_idle_menu( - self, mock_audio, mock_tts, mock_media_client, mock_media_store, - mock_error_queue, tmp_path): - """Digit dialed during IDLE_DIAL_TONE transitions to IDLE_MENU.""" - menu = self._make_menu_with_content(mock_audio, mock_tts, mock_media_client, - mock_media_store, mock_error_queue, tmp_path) menu.on_handset_lifted(now=0.0) - assert menu.state == MenuState.IDLE_DIAL_TONE - # Digit arrives well before the DIAL_TONE_TIMEOUT_IDLE (5s) - menu.on_digit(1, now=0.1) - # Advance past disambiguation timeout (1.5s) but still within dial-tone window - menu.tick(now=0.1 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) - assert menu.state == MenuState.IDLE_MENU, \ - f"Expected IDLE_MENU after digit-before-menu guard, got {menu.state}" + menu.on_digit(1, now=0.5) + assert menu.state == MenuState.DIRECT_DIAL, \ + f"Expected DIRECT_DIAL for non-zero digit, got {menu.state}" - def test_digit_during_idle_dial_tone_no_not_in_service( + def test_nonzero_digit_does_not_speak_not_in_service( self, mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path): - """SCRIPT_NOT_IN_SERVICE must NOT be spoken when digit is dialed during IDLE_DIAL_TONE.""" - menu = self._make_menu_with_content(mock_audio, mock_tts, mock_media_client, - mock_media_store, mock_error_queue, tmp_path) + """Non-zero digit enters DIRECT_DIAL — no 'not in service' spoken yet.""" + mock_media_store.set_playlists([MediaItem("/p/1", "Jazz Mix", "playlist")]) + mock_media_client.set_now_playing(PlaybackState(item=None, is_paused=False)) + menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, + mock_error_queue, tmp_path) menu.on_handset_lifted(now=0.0) - menu.on_digit(1, now=0.1) - menu.tick(now=0.1 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.on_digit(1, now=0.5) assert not tts_spoke(mock_tts, "not in service"), \ - f"SCRIPT_NOT_IN_SERVICE should not be spoken; tts calls: {tts_calls(mock_tts)}" + f"SCRIPT_NOT_IN_SERVICE must not be spoken on first digit; calls: {tts_calls(mock_tts)}" - def test_digit_during_idle_dial_tone_stops_dial_tone( + def test_any_digit_stops_dial_tone( self, mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path): - """The dial tone must be stopped before the menu prompt is delivered.""" - menu = self._make_menu_with_content(mock_audio, mock_tts, mock_media_client, - mock_media_store, mock_error_queue, tmp_path) - menu.on_handset_lifted(now=0.0) - mock_audio.calls.clear() # Clear the play_tone from handset lift - menu.on_digit(1, now=0.1) - menu.tick(now=0.1 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) - # stop() must appear in audio calls before any speak_and_play - audio_stops = [i for i, c in enumerate(mock_audio.calls) if c[0] == 'stop'] - assert audio_stops, \ - f"Expected audio.stop() to be called; audio calls: {mock_audio.calls}" - - def test_digit_during_idle_dial_tone_digit_dropped( - self, mock_audio, mock_tts, mock_media_client, mock_media_store, - mock_error_queue, tmp_path): - """The queued digit is dropped — no navigation action is taken on it.""" - menu = self._make_menu_with_content(mock_audio, mock_tts, mock_media_client, - mock_media_store, mock_error_queue, tmp_path) + """Any digit received in IDLE_DIAL_TONE → dial tone stopped immediately.""" + mock_media_store.set_playlists([MediaItem("/p/1", "Jazz Mix", "playlist")]) + mock_media_client.set_now_playing(PlaybackState(item=None, is_paused=False)) + menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, + mock_error_queue, tmp_path) menu.on_handset_lifted(now=0.0) - menu.on_digit(1, now=0.1) - menu.tick(now=0.1 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) - # Digit 1 in IDLE_MENU would navigate to playlists (BROWSE_PLAYLISTS) - # but since it was dropped we should still be at IDLE_MENU - assert menu.state == MenuState.IDLE_MENU, \ - f"Digit should be dropped; expected IDLE_MENU, got {menu.state}" + mock_audio.calls.clear() + menu.on_digit(1, now=0.5) + audio_stops = [c for c in mock_audio.calls if c[0] == 'stop'] + assert audio_stops, f"Expected audio.stop() on digit; calls: {mock_audio.calls}" - def test_digit_during_idle_dial_tone_while_playing_delivers_playing_menu( + def test_digit_0_delivers_playing_menu_when_music_active( self, mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path): - """When music is playing and digit arrives during IDLE_DIAL_TONE, - the PLAYING_MENU is delivered (not the idle menu).""" + """Digit 0 while music playing → PLAYING_MENU (not idle menu).""" mock_media_store.set_playlists([MediaItem("/p/1", "Jazz Mix", "playlist")]) now_playing_item = MediaItem("/tracks/1", "Some Song", "track") mock_media_client.set_now_playing(PlaybackState(item=now_playing_item, is_paused=False)) + mock_media_client.set_queue_position(1, 5) menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_handset_lifted(now=0.0) - assert menu.state == MenuState.IDLE_DIAL_TONE - menu.on_digit(1, now=0.1) - menu.tick(now=0.1 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.on_digit(0, now=0.5) assert menu.state == MenuState.PLAYING_MENU, \ f"Expected PLAYING_MENU when music playing, got {menu.state}" @@ -2258,8 +2243,6 @@ def test_digit_during_idle_dial_tone_while_playing_delivers_playing_menu( SCRIPT_RADIO_PLAYING_MENU_FRAGMENT = "To disconnect your call, dial three" SCRIPT_RADIO_PLAYING_GREETING_FRAGMENT = "currently tuned to" -from src.constants import DIAL_TONE_TIMEOUT_PLAYING - def _seed_radio_entry(phone_book, media_key="radio:90300000.0", name="NPR", media_type="radio"): """Seed a radio entry into the phone book and return its phone number.""" @@ -2289,7 +2272,6 @@ def _make_menu_with_radio(self, mock_audio, mock_tts, mock_media_client, mock_me radio=radio, ) menu.on_handset_lifted(now=_T0) - menu.tick(now=10.0) # past dial tone → idle menu mock_tts.calls.clear() mock_media_client.calls.clear() return menu, phone_book @@ -2352,7 +2334,7 @@ def test_radio_stops_existing_stream_before_new_dial( def test_radio_playing_menu_on_handset_lift( self, mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path): - """Lifting handset while radio is playing (Plex idle) → RADIO_PLAYING_MENU after timeout.""" + """Lifting handset while radio is playing (Plex idle) → dialing 0 delivers RADIO_PLAYING_MENU.""" from src.radio import MockRadio radio = MockRadio() radio.set_playing(True) @@ -2361,8 +2343,7 @@ def test_radio_playing_menu_on_handset_lift( tmp_path, radio=radio) menu.on_handset_lifted(now=_T0) mock_tts.calls.clear() - # Tick past DIAL_TONE_TIMEOUT_PLAYING - menu.tick(now=_T0 + DIAL_TONE_TIMEOUT_PLAYING + 0.1) + menu.on_digit(0, now=0.5) assert menu.state == MenuState.RADIO_PLAYING_MENU, \ f"Expected RADIO_PLAYING_MENU, got {menu.state}" @@ -2384,7 +2365,7 @@ def test_radio_playing_menu_digit_3_stops_radio( radio.calls.clear() mock_tts.calls.clear() menu.on_digit(3, now=15.0) - menu.tick(now=15.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=15.0 + 0.1) assert menu.state == MenuState.IDLE_MENU, \ f"Expected IDLE_MENU after digit 3; got {menu.state}" @@ -2405,7 +2386,7 @@ def test_radio_playing_menu_digit_0_stops_radio( radio.calls.clear() mock_tts.calls.clear() menu.on_digit(0, now=15.0) - menu.tick(now=15.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=15.0 + 0.1) assert menu.state == MenuState.IDLE_MENU, \ f"Expected IDLE_MENU after digit 0; got {menu.state}" @@ -2426,7 +2407,7 @@ def test_radio_playing_menu_invalid_digit( radio.calls.clear() mock_tts.calls.clear() menu.on_digit(1, now=15.0) - menu.tick(now=15.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=15.0 + 0.1) texts = tts_calls(mock_tts) assert any(SCRIPT_NOT_IN_SERVICE in t for t in texts), \ @@ -2452,29 +2433,6 @@ def test_hangup_does_not_stop_radio( assert len(radio_stops) == 0, \ f"Hang-up must not stop radio; calls: {radio.calls}" - def test_radio_uses_playing_timeout_when_active( - self, mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path): - """When radio is active, DIAL_TONE_TIMEOUT_PLAYING applies (shorter timeout).""" - from src.radio import MockRadio - from src.constants import DIAL_TONE_TIMEOUT_IDLE - radio = MockRadio() - radio.set_playing(True) - mock_media_client.set_now_playing(PlaybackState(item=None, is_paused=False)) - menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, - tmp_path, radio=radio) - menu.on_handset_lifted(now=_T0) - mock_tts.calls.clear() - - # Just before DIAL_TONE_TIMEOUT_PLAYING — should still be in IDLE_DIAL_TONE - menu.tick(now=_T0 + DIAL_TONE_TIMEOUT_PLAYING - 0.05) - assert menu.state == MenuState.IDLE_DIAL_TONE, \ - f"Expected still IDLE_DIAL_TONE before timeout; got {menu.state}" - - # Just past DIAL_TONE_TIMEOUT_PLAYING — should now be in RADIO_PLAYING_MENU - menu.tick(now=_T0 + DIAL_TONE_TIMEOUT_PLAYING + 0.1) - assert menu.state == MenuState.RADIO_PLAYING_MENU, \ - f"Expected RADIO_PLAYING_MENU after timeout; got {menu.state}" - # --------------------------------------------------------------------------- # F-23: Narrow broad except Exception handlers @@ -2509,7 +2467,7 @@ def refresh(self): raise sqlite3.Error("DB locked") menu = make_menu(mock_audio, mock_tts, mock_media_client, SqliteFailingStore(), mock_error_queue, tmp_path) menu.on_handset_lifted(now=_T0) - menu.tick(now=10.0) + menu.on_digit(0, now=0.5) texts = tts_calls(mock_tts) assert any(SCRIPT_MEDIA_FAILURE in t for t in texts), \ f"Expected SCRIPT_MEDIA_FAILURE for sqlite3.Error; got: {texts}" @@ -2537,7 +2495,7 @@ def refresh(self): raise OSError("Network error") menu = make_menu(mock_audio, mock_tts, mock_media_client, OsErrorFailingStore(), mock_error_queue, tmp_path) menu.on_handset_lifted(now=_T0) - menu.tick(now=10.0) + menu.on_digit(0, now=0.5) texts = tts_calls(mock_tts) assert any(SCRIPT_MEDIA_FAILURE in t for t in texts), \ f"Expected SCRIPT_MEDIA_FAILURE for OSError; got: {texts}" @@ -2564,7 +2522,6 @@ def test_phone_book_lookup_sqlite_error_treated_as_not_found( menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_handset_lifted(now=_T0) - menu.tick(now=10.0) # Patch the phone_book to raise sqlite3.Error on lookup def raise_sqlite(*args, **kwargs): @@ -2573,10 +2530,9 @@ def raise_sqlite(*args, **kwargs): menu._phone_book.lookup_by_phone_number = raise_sqlite mock_tts.calls.clear() - # Dial a 7-digit number (not ASSISTANT_NUMBER) - for digit in [1, 2, 3, 4, 5, 6, 7]: - menu.on_digit(digit, now=15.0 + digit * 0.1) - menu.tick(now=20.0) + # Dial a 7-digit number from IDLE_DIAL_TONE (first non-zero digit enters DIRECT_DIAL) + for i, digit in enumerate([1, 2, 3, 4, 5, 6, 7]): + menu.on_digit(digit, now=1.0 + i * 0.1) texts = tts_calls(mock_tts) assert any(SCRIPT_NOT_IN_SERVICE in t for t in texts), \ @@ -2594,7 +2550,6 @@ def test_phone_book_lookup_sqlite_error_logs_to_error_queue( menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_handset_lifted(now=_T0) - menu.tick(now=10.0) # Patch the phone_book to raise sqlite3.Error on lookup def raise_sqlite(*args, **kwargs): @@ -2603,10 +2558,9 @@ def raise_sqlite(*args, **kwargs): menu._phone_book.lookup_by_phone_number = raise_sqlite mock_error_queue.logged_calls.clear() - # Dial a 7-digit number (not ASSISTANT_NUMBER) - for digit in [1, 2, 3, 4, 5, 6, 7]: - menu.on_digit(digit, now=15.0 + digit * 0.1) - menu.tick(now=20.0) + # Dial a 7-digit number from IDLE_DIAL_TONE (first non-zero digit enters DIRECT_DIAL) + for i, digit in enumerate([1, 2, 3, 4, 5, 6, 7]): + menu.on_digit(digit, now=1.0 + i * 0.1) assert len(mock_error_queue.logged_calls) >= 1, \ "Expected error_queue.log to be called when phone_book.lookup_by_phone_number raises" @@ -2626,8 +2580,7 @@ def _enter_assistant(self, menu, mock_media_store, mock_tts): mock_media_store.set_artists([]) mock_media_store.set_genres([]) menu.on_handset_lifted(now=_T0) - menu.tick(now=10.0) - _dial_number(menu, ASSISTANT_NUMBER, start_time=11.0) + _dial_number(menu, ASSISTANT_NUMBER, start_time=1.0) assert menu.state == MenuState.ASSISTANT mock_tts.calls.clear() @@ -2646,7 +2599,7 @@ def raise_sqlite(): menu._media_store.refresh = raise_sqlite menu.on_digit(1, now=20.0) - menu.tick(now=20.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=20.0 + 0.1) texts = tts_calls(mock_tts) assert any(SCRIPT_ASSISTANT_REFRESH_FAILURE in t for t in texts), \ @@ -2666,7 +2619,7 @@ def raise_oserror(): menu._media_store.refresh = raise_oserror menu.on_digit(1, now=20.0) - menu.tick(now=20.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.tick(now=20.0 + 0.1) texts = tts_calls(mock_tts) assert any(SCRIPT_ASSISTANT_REFRESH_FAILURE in t for t in texts), \ @@ -2689,70 +2642,6 @@ def _dial_unknown_number(self, menu, start_time=15.0): for i, d in enumerate(digits[2:], start=2): menu.on_digit(d, now=start_time + i * 0.1) - def test_failed_direct_dial_from_idle_menu_speaks_not_in_service( - self, mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path): - """Failed direct dial from IDLE_MENU → speaks SCRIPT_NOT_IN_SERVICE.""" - mock_media_store.set_playlists([MediaItem("/p/1", "Jazz", "playlist")]) - mock_media_store.set_artists([]) - mock_media_store.set_genres([]) - mock_media_client.set_now_playing(PlaybackState(item=None, is_paused=False)) - menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) - menu.on_handset_lifted(now=_T0) - menu.tick(now=10.0) # deliver idle menu; state = IDLE_MENU - assert menu.state == MenuState.IDLE_MENU - mock_tts.calls.clear() - - self._dial_unknown_number(menu, start_time=15.0) - - texts = tts_calls(mock_tts) - assert any(SCRIPT_NOT_IN_SERVICE in t for t in texts), \ - f"Expected SCRIPT_NOT_IN_SERVICE after failed dial; got: {texts}" - - def test_failed_direct_dial_from_idle_menu_re_delivers_idle_menu( - self, mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path): - """Failed direct dial from IDLE_MENU → re-delivers idle menu (state = IDLE_MENU).""" - mock_media_store.set_playlists([MediaItem("/p/1", "Jazz", "playlist")]) - mock_media_store.set_artists([]) - mock_media_store.set_genres([]) - mock_media_client.set_now_playing(PlaybackState(item=None, is_paused=False)) - menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) - menu.on_handset_lifted(now=_T0) - menu.tick(now=10.0) # deliver idle menu; state = IDLE_MENU - assert menu.state == MenuState.IDLE_MENU - mock_tts.calls.clear() - - self._dial_unknown_number(menu, start_time=15.0) - - assert menu.state == MenuState.IDLE_MENU, \ - f"Expected IDLE_MENU after failed dial from IDLE_MENU; got {menu.state}" - # Idle menu prompt should be re-delivered - texts = tts_calls(mock_tts) - assert any(SCRIPT_IDLE_MENU in t for t in texts), \ - f"Expected idle menu re-delivered after failed dial; texts: {texts}" - - def test_failed_direct_dial_from_browse_artists_re_delivers_browse_prompt( - self, mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path): - """Failed direct dial from BROWSE_ARTISTS → speaks not-in-service then re-delivers browse prompt.""" - from src.menu import SCRIPT_BROWSE_PROMPT_ARTIST - items = [MediaItem("/a/1", "Beatles", "artist")] - menu = _make_browse_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, - mock_error_queue, tmp_path, items, category="artist") - assert menu.state == MenuState.BROWSE_ARTISTS, \ - f"Expected BROWSE_ARTISTS before dialing; got {menu.state}" - mock_tts.calls.clear() - - self._dial_unknown_number(menu, start_time=20.0) - - texts = tts_calls(mock_tts) - assert any(SCRIPT_NOT_IN_SERVICE in t for t in texts), \ - f"Expected SCRIPT_NOT_IN_SERVICE; got: {texts}" - # State should be restored to BROWSE_ARTISTS - assert menu.state == MenuState.BROWSE_ARTISTS, \ - f"Expected BROWSE_ARTISTS after failed dial; got {menu.state}" - # Browse prompt should be re-delivered - assert any(SCRIPT_BROWSE_PROMPT_ARTIST in t for t in texts), \ - f"Expected artist browse prompt re-delivered; texts: {texts}" - def test_failed_direct_dial_before_any_menu_delivers_idle_menu( self, mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path): """Failed direct dial from IDLE_DIAL_TONE (before any menu) → delivers correct top-level menu.""" @@ -2762,15 +2651,12 @@ def test_failed_direct_dial_before_any_menu_delivers_idle_menu( mock_media_client.set_now_playing(PlaybackState(item=None, is_paused=False)) menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_handset_lifted(now=_T0) - # Do NOT tick past the dial tone timeout — state stays IDLE_DIAL_TONE assert menu.state == MenuState.IDLE_DIAL_TONE - # Dial two digits quickly to enter DIRECT_DIAL while still in IDLE_DIAL_TONE + # First non-zero digit enters DIRECT_DIAL immediately menu.on_digit(1, now=_T0 + 0.1) - menu.on_digit(2, now=_T0 + 0.15) assert menu.state == MenuState.DIRECT_DIAL - # Dial remaining 5 digits - for i, d in enumerate([3, 4, 5, 6, 7], start=2): + for i, d in enumerate([2, 3, 4, 5, 6, 7], start=1): menu.on_digit(d, now=_T0 + 0.1 + i * 0.1) texts = tts_calls(mock_tts) @@ -2793,15 +2679,12 @@ def test_failed_direct_dial_before_any_menu_while_playing_delivers_playing_menu( mock_media_client.set_now_playing(PlaybackState(item=playing_item, is_paused=False)) menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_handset_lifted(now=_T0) - # Do NOT tick past the dial tone timeout — state stays IDLE_DIAL_TONE assert menu.state == MenuState.IDLE_DIAL_TONE - # Dial two digits quickly to enter DIRECT_DIAL while still in IDLE_DIAL_TONE + # First non-zero digit enters DIRECT_DIAL immediately menu.on_digit(1, now=_T0 + 0.1) - menu.on_digit(2, now=_T0 + 0.15) assert menu.state == MenuState.DIRECT_DIAL - # Dial remaining 5 digits - for i, d in enumerate([3, 4, 5, 6, 7], start=2): + for i, d in enumerate([2, 3, 4, 5, 6, 7], start=1): menu.on_digit(d, now=_T0 + 0.1 + i * 0.1) texts = tts_calls(mock_tts) @@ -2830,16 +2713,14 @@ def test_successful_direct_dial_unaffected( menu._phone_book = phone_book menu.on_handset_lifted(now=_T0) - menu.tick(now=10.0) # IDLE_MENU mock_tts.calls.clear() mock_media_client.calls.clear() - # Dial the number + # Dial the number from IDLE_DIAL_TONE (first non-zero digit enters DIRECT_DIAL) digits = [int(c) for c in number] - menu.on_digit(digits[0], now=15.0) - menu.on_digit(digits[1], now=15.05) - for i, d in enumerate(digits[2:], start=2): - menu.on_digit(d, now=15.0 + i * 0.1) + menu.on_digit(digits[0], now=1.0) + for i, d in enumerate(digits[1:], start=1): + menu.on_digit(d, now=1.0 + i * 0.05) # Should have transitioned to PLAYING_MENU assert menu.state == MenuState.PLAYING_MENU, \ @@ -2849,54 +2730,6 @@ def test_successful_direct_dial_unaffected( assert not any(SCRIPT_NOT_IN_SERVICE in t for t in texts), \ f"SCRIPT_NOT_IN_SERVICE should not be spoken on success; got: {texts}" - def test_pre_dial_state_cleared_on_cradle( - self, mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path): - """on_handset_on_cradle() clears _pre_dial_state.""" - mock_media_store.set_playlists([MediaItem("/p/1", "Jazz", "playlist")]) - mock_media_store.set_artists([]) - mock_media_store.set_genres([]) - mock_media_client.set_now_playing(PlaybackState(item=None, is_paused=False)) - menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) - menu.on_handset_lifted(now=_T0) - menu.tick(now=10.0) - - # Enter direct dial to set _pre_dial_state - menu.on_digit(1, now=15.0) - menu.on_digit(2, now=15.05) - assert menu.state == MenuState.DIRECT_DIAL - assert menu._pre_dial_state is not None - - # Hang up — _pre_dial_state should be cleared - menu.on_handset_on_cradle() - assert menu._pre_dial_state is None, \ - f"Expected _pre_dial_state=None after cradle; got {menu._pre_dial_state}" - - def test_failed_direct_dial_from_playing_menu_re_delivers_playing_menu( - self, mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path): - """Failed direct dial from PLAYING_MENU → speaks not-in-service then re-delivers playing menu.""" - mock_media_store.set_playlists([MediaItem("/p/1", "Jazz", "playlist")]) - mock_media_store.set_artists([]) - mock_media_store.set_genres([]) - playing_item = MediaItem("/t/1", "Some Song", "track") - mock_media_client.set_now_playing(PlaybackState(item=playing_item, is_paused=False)) - mock_media_client.set_queue_position(1, 5) - menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) - menu.on_handset_lifted(now=_T0) - menu.tick(now=10.0) # deliver playing menu; state = PLAYING_MENU - assert menu.state == MenuState.PLAYING_MENU - mock_tts.calls.clear() - - self._dial_unknown_number(menu, start_time=15.0) - - texts = tts_calls(mock_tts) - assert any(SCRIPT_NOT_IN_SERVICE in t for t in texts), \ - f"Expected SCRIPT_NOT_IN_SERVICE after failed dial; got: {texts}" - assert menu.state == MenuState.PLAYING_MENU, \ - f"Expected PLAYING_MENU after failed dial from PLAYING_MENU; got {menu.state}" - # Playing menu must be re-announced so user knows their options - assert any(playing_item.name in t for t in texts), \ - f"Expected playing menu re-delivered (song name in TTS); got: {texts}" - def test_db_error_during_lookup_speaks_not_in_service_and_re_delivers_menu( self, mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path): """SQLite error during phone book lookup → speaks not-in-service and re-delivers prior menu.""" @@ -2908,13 +2741,12 @@ def test_db_error_during_lookup_speaks_not_in_service_and_re_delivers_menu( mock_media_client.set_now_playing(PlaybackState(item=None, is_paused=False)) menu = make_menu(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) menu.on_handset_lifted(now=_T0) - menu.tick(now=10.0) # deliver idle menu; state = IDLE_MENU - assert menu.state == MenuState.IDLE_MENU + assert menu.state == MenuState.IDLE_DIAL_TONE mock_tts.calls.clear() with patch.object(menu._phone_book, 'lookup_by_phone_number', side_effect=sqlite3.Error("simulated DB failure")): - self._dial_unknown_number(menu, start_time=15.0) + self._dial_unknown_number(menu, start_time=1.0) texts = tts_calls(mock_tts) assert any(SCRIPT_NOT_IN_SERVICE in t for t in texts), \ @@ -3006,20 +2838,17 @@ def test_current_artist_cleared_on_handset_on_cradle( mock_error_queue, tmp_path) menu.on_handset_lifted(now=0.0) - # Advance past dial-tone timeout → idle menu - menu.tick(now=DIAL_TONE_TIMEOUT_IDLE + 0.1) + menu.on_digit(0, now=0.5) # enter operator menu assert menu.state == MenuState.IDLE_MENU # Digit 1 → browse artists (only option) - t = DIAL_TONE_TIMEOUT_IDLE + 0.2 - menu.on_digit(1, now=t) - menu.tick(now=t + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.on_digit(1, now=1.0) + menu.tick(now=1.1) assert menu.state == MenuState.BROWSE_ARTISTS # Digit 4 (T9 for 'L') → single match → auto-selects Led Zeppelin → ARTIST_SUBMENU - t2 = t + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.5 - menu.on_digit(4, now=t2) - menu.tick(now=t2 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) + menu.on_digit(4, now=2.0) + menu.tick(now=2.1) assert menu.state == MenuState.ARTIST_SUBMENU assert menu._current_artist is not None, "precondition: artist must be set" diff --git a/tests/test_phone.py b/tests/test_phone.py new file mode 100644 index 0000000..df8bea9 --- /dev/null +++ b/tests/test_phone.py @@ -0,0 +1,369 @@ +"""Tests for src/phone.py — Phone lifecycle. + +All tests use MagicMock dependencies; no hardware or network required. + +NOTE: there is a known bug in Phone.__init__ — the `tts` parameter is +accepted but never stored as self._tts, so _on_handset_replaced raises +AttributeError when it calls self._tts.abort(). test_replaces_handset_aborts_tts +will fail until that is fixed. +""" + +import threading +import time +from unittest.mock import MagicMock, patch +import pytest + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_phone(**overrides): + """Return a Phone built from MagicMock dependencies. + + Dialer is patched so no real GPIO poll thread is started. + """ + from src.phone import Phone + deps = dict( + hook=MagicMock(), + pulse=MagicMock(), + tts=MagicMock(), + audio=MagicMock(), + menu=MagicMock(), + ) + deps.update(overrides) + with patch("src.phone.Dialer"): + phone = Phone(**deps) + phone._test_tts = deps["tts"] + phone._test_audio = deps["audio"] + phone._test_menu = deps["menu"] + return phone + + +# --------------------------------------------------------------------------- +# Construction +# --------------------------------------------------------------------------- + +class TestPhoneInit: + + def test_handset_not_lifted_at_creation(self): + assert _make_phone()._handset_lifted is False + + def test_stop_event_clear_at_creation(self): + assert not _make_phone()._stop_event.is_set() + + def test_dialer_created_with_menu_on_digit_and_pulse(self): + menu = MagicMock() + pulse = MagicMock() + from src.phone import Phone + with patch("src.phone.Dialer") as MockDialer: + Phone(hook=MagicMock(), pulse=pulse, tts=MagicMock(), + audio=MagicMock(), menu=menu) + MockDialer.assert_called_once_with(menu.on_digit, pulse) + + +# --------------------------------------------------------------------------- +# start() / stop() +# --------------------------------------------------------------------------- + +class TestPhoneStartStop: + + def test_start_spawns_hook_watcher_thread(self): + phone = _make_phone() + phone.start() + names = [t.name for t in threading.enumerate()] + phone._stop_event.set() + assert "hook-watcher" in names + + def test_hook_watcher_thread_is_daemon(self): + phone = _make_phone() + phone.start() + thread = next(t for t in threading.enumerate() if t.name == "hook-watcher") + phone._stop_event.set() + assert thread.daemon + + def test_stop_sets_stop_event(self): + phone = _make_phone() + phone._handset_lifted = False + phone.stop() + assert phone._stop_event.is_set() + + def test_stop_delegates_to_on_handset_replaced(self): + phone = _make_phone() + with patch.object(phone, "_on_handset_replaced") as mock: + phone.stop() + mock.assert_called_once() + + +# --------------------------------------------------------------------------- +# Hook watcher thread +# --------------------------------------------------------------------------- + +class TestHookWatcher: + + def test_registers_lifted_callback_on_hook(self): + hook = MagicMock() + phone = _make_phone(hook=hook) + + def stop_soon(): + time.sleep(0.010) + phone._stop_event.set() + + t = threading.Thread(target=stop_soon, daemon=True) + t.start() + phone._start_hook_watcher() + t.join() + assert hook.when_pressed == phone._on_handset_lifted + + def test_registers_replaced_callback_on_hook(self): + hook = MagicMock() + phone = _make_phone(hook=hook) + + def stop_soon(): + time.sleep(0.010) + phone._stop_event.set() + + t = threading.Thread(target=stop_soon, daemon=True) + t.start() + phone._start_hook_watcher() + t.join() + assert hook.when_released == phone._on_handset_replaced + + def test_exits_promptly_when_stop_event_set(self): + phone = _make_phone() + phone._stop_event.set() + start = time.monotonic() + phone._start_hook_watcher() + assert time.monotonic() - start < 0.5 + + def test_exception_in_loop_does_not_kill_watcher(self): + hook = MagicMock() + call_count = [0] + + original_setattr = type(hook).__setattr__ + + def raise_once(self, name, value): + call_count[0] += 1 + if call_count[0] == 1: + raise RuntimeError("transient error") + original_setattr(self, name, value) + + phone = _make_phone(hook=hook) + + def stop_after_two(): + while call_count[0] < 2: + time.sleep(0.001) + phone._stop_event.set() + + with patch.object(type(hook), "__setattr__", raise_once): + t = threading.Thread(target=stop_after_two, daemon=True) + t.start() + phone._start_hook_watcher() + t.join() + + assert call_count[0] >= 2 + + +# --------------------------------------------------------------------------- +# _on_handset_lifted +# --------------------------------------------------------------------------- + +class TestOnHandsetLifted: + + def _lifted(self, phone): + """Lift the handset and wait for the session thread to start, then stop it.""" + started = threading.Event() + original = phone._start_phone_session + + def tracked(): + started.set() + # let the real loop run briefly then exit + phone._handset_lifted = False + + phone._start_phone_session = tracked + phone._on_handset_lifted() + started.wait(timeout=1.0) + phone._start_phone_session = original + + def test_sets_handset_lifted_flag(self): + phone = _make_phone() + with patch.object(phone, "_start_phone_session"): + phone._on_handset_lifted() + assert phone._handset_lifted is True + + def test_turns_amp_on(self): + audio = MagicMock() + phone = _make_phone(audio=audio) + with patch.object(phone, "_start_phone_session"): + phone._on_handset_lifted() + audio.amp_on.assert_called_once() + + def test_starts_dialer(self): + phone = _make_phone() + with patch.object(phone, "_start_phone_session"): + phone._on_handset_lifted() + phone._dialer.start.assert_called_once() + + def test_notifies_menu(self): + menu = MagicMock() + phone = _make_phone(menu=menu) + with patch.object(phone, "_start_phone_session"): + phone._on_handset_lifted() + menu.on_handset_lifted.assert_called_once() + + def test_starts_phone_session_in_thread(self): + phone = _make_phone() + started = threading.Event() + + def signal_start(): + started.set() + phone._handset_lifted = False + + with patch.object(phone, "_start_phone_session", side_effect=signal_start): + phone._on_handset_lifted() + assert started.wait(timeout=1.0), "phone-session thread never started" + + def test_phone_session_thread_is_daemon(self): + phone = _make_phone() + started = threading.Event() + + def signal_start(): + started.set() + phone._handset_lifted = False + + with patch.object(phone, "_start_phone_session", side_effect=signal_start): + phone._on_handset_lifted() + started.wait(timeout=1.0) + + threads = [t for t in threading.enumerate() if t.name == "phone-session"] + # thread may have already exited; if still alive it must be a daemon + for t in threads: + assert t.daemon + + def test_idempotent_when_already_lifted(self): + audio = MagicMock() + phone = _make_phone(audio=audio) + phone._handset_lifted = True + with patch.object(phone, "_start_phone_session"): + phone._on_handset_lifted() + audio.amp_on.assert_not_called() + + +# --------------------------------------------------------------------------- +# _on_handset_replaced +# --------------------------------------------------------------------------- + +class TestOnHandsetReplaced: + + def _lifted_phone(self, **overrides): + phone = _make_phone(**overrides) + phone._handset_lifted = True + return phone + + def test_clears_handset_lifted_flag(self): + phone = self._lifted_phone() + phone._on_handset_replaced() + assert phone._handset_lifted is False + + def test_turns_amp_off(self): + audio = MagicMock() + phone = self._lifted_phone(audio=audio) + phone._on_handset_replaced() + audio.amp_off.assert_called_once() + + def test_stops_dialer(self): + phone = self._lifted_phone() + phone._on_handset_replaced() + phone._dialer.stop.assert_called_once() + + def test_notifies_menu(self): + menu = MagicMock() + phone = self._lifted_phone(menu=menu) + phone._on_handset_replaced() + menu.on_handset_on_cradle.assert_called_once() + + def test_aborts_tts(self): + # NOTE: this test exposes a bug — Phone.__init__ accepts tts but never + # assigns self._tts, so this will raise AttributeError until fixed. + tts = MagicMock() + phone = self._lifted_phone(tts=tts) + phone._on_handset_replaced() + tts.abort.assert_called_once() + + def test_idempotent_when_already_on_cradle(self): + audio = MagicMock() + phone = _make_phone(audio=audio) + phone._handset_lifted = False + phone._on_handset_replaced() + audio.amp_off.assert_not_called() + + +# --------------------------------------------------------------------------- +# _start_phone_session +# --------------------------------------------------------------------------- + +class TestPhoneSession: + + def test_tick_called_while_handset_lifted(self): + menu = MagicMock() + phone = _make_phone(menu=menu) + phone._handset_lifted = True + + def put_down(): + time.sleep(0.020) + phone._handset_lifted = False + + t = threading.Thread(target=put_down, daemon=True) + t.start() + phone._start_phone_session() + t.join() + + assert menu.tick.call_count >= 1 + + def test_session_exits_once_handset_lowered(self): + phone = _make_phone() + phone._handset_lifted = True + + def put_down(): + time.sleep(0.015) + phone._handset_lifted = False + + t = threading.Thread(target=put_down, daemon=True) + t.start() + start = time.monotonic() + phone._start_phone_session() + elapsed = time.monotonic() - start + t.join() + + assert elapsed < 0.5 + + def test_tick_not_called_when_handset_not_lifted(self): + menu = MagicMock() + phone = _make_phone(menu=menu) + phone._handset_lifted = False + phone._start_phone_session() + menu.tick.assert_not_called() + + def test_exception_in_tick_does_not_kill_session(self): + menu = MagicMock() + tick_count = [0] + + def tick_raise_once(**kwargs): + tick_count[0] += 1 + if tick_count[0] == 1: + raise RuntimeError("transient tick error") + + menu.tick.side_effect = tick_raise_once + phone = _make_phone(menu=menu) + phone._handset_lifted = True + + def put_down(): + time.sleep(0.020) + phone._handset_lifted = False + + t = threading.Thread(target=put_down, daemon=True) + t.start() + phone._start_phone_session() + t.join() + + assert tick_count[0] >= 2 diff --git a/tests/test_session.py b/tests/test_session.py index 3611e86..df6c67d 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -3,13 +3,13 @@ All tests use injected mocks; no hardware or network required. """ +import time import pytest from src.gpio_handler import GpioEvent from src.interfaces import MediaItem, PlaybackState from src.menu import MenuState from src.constants import ( - DIRECT_DIAL_DISAMBIGUATION_TIMEOUT, PHONE_NUMBER_LENGTH, - DIAL_TONE_TIMEOUT_IDLE, DIAL_TONE_TIMEOUT_PLAYING, + DIAL_ENTRY_TIMEOUT, PHONE_NUMBER_LENGTH, ) @@ -61,30 +61,39 @@ def test_handset_lifted_starts_dial_tone( mock_error_queue, tmp_path) assert any(c[0] == 'play_tone' for c in mock_audio.calls) - def test_dial_tone_timeout_idle( + def test_digit_0_from_idle_dial_tone_delivers_idle_menu( self, mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path): - """No digit within idle timeout → dial tone stops, idle menu prompt begins.""" + """Digit 0 from IDLE_DIAL_TONE (no music) → idle menu delivered.""" from src.menu import SCRIPT_GREETING mock_media_store.set_playlists([MediaItem("/pl/1", "Jazz", "playlist")]) mock_media_store.set_artists([]) mock_media_store.set_genres([]) session = make_session(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) - session.tick(now=DIAL_TONE_TIMEOUT_IDLE + 1.0) + session.handle_event((GpioEvent.DIGIT_DIALED, 0), now=0.5) texts = tts_calls(mock_tts) assert any(SCRIPT_GREETING in t for t in texts) - def test_dial_tone_timeout_playing( + def test_digit_0_from_idle_dial_tone_while_playing_delivers_playing_menu( self, mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path): - """No digit within playing timeout → playing menu prompt begins.""" + """Digit 0 from IDLE_DIAL_TONE while music playing → playing menu delivered.""" from src.menu import SCRIPT_PLAYING_MENU_DEFAULT mock_media_client.set_now_playing(PlaybackState(MediaItem("/pl/1", "Jazz", "playlist"), False)) session = make_session(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) - session.tick(now=DIAL_TONE_TIMEOUT_PLAYING + 1.0) + session.handle_event((GpioEvent.DIGIT_DIALED, 0), now=0.5) texts = tts_calls(mock_tts) assert any("Jazz" in t for t in texts) or any(SCRIPT_PLAYING_MENU_DEFAULT in t for t in texts) + def test_dial_entry_timeout_triggers_off_hook( + self, mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path): + """No digit within DIAL_ENTRY_TIMEOUT → off-hook warning tone.""" + session = make_session(mock_audio, mock_tts, mock_media_client, mock_media_store, + mock_error_queue, tmp_path) + session.tick(now=DIAL_ENTRY_TIMEOUT + 1.0) + from src.menu import MenuState + assert session.menu.state == MenuState.OFF_HOOK + def test_handset_on_cradle_stops_audio( self, mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path): """session.close() → all audio stops.""" @@ -122,7 +131,7 @@ class TestSessionDirectDial: def test_direct_dial_during_dial_tone( self, mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path): - """Two digits within disambiguation timeout during dial tone → DTMF tones played.""" + """Non-zero digit from IDLE_DIAL_TONE enters DIRECT_DIAL → DTMF tones played.""" session = make_session(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) session.handle_event((GpioEvent.DIGIT_DIALED, 5), now=0.5) @@ -130,24 +139,6 @@ def test_direct_dial_during_dial_tone( dtmf_calls = [c for c in mock_audio.calls if c[0] == 'play_dtmf'] assert len(dtmf_calls) >= 2 - def test_single_digit_during_dial_tone_treated_as_navigation( - self, mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path): - """One digit during dial tone, no second → treated as menu input.""" - from src.menu import SCRIPT_GREETING - mock_media_store.set_playlists([MediaItem("/pl/1", "Jazz", "playlist")]) - mock_media_store.set_artists([]) - mock_media_store.set_genres([]) - session = make_session(mock_audio, mock_tts, mock_media_client, mock_media_store, - mock_error_queue, tmp_path) - session.tick(now=DIAL_TONE_TIMEOUT_IDLE + 1.0) # deliver idle menu - mock_tts.calls.clear() - session.handle_event((GpioEvent.DIGIT_DIALED, 1), now=10.0) - # Wait for disambiguation timeout - session.tick(now=10.0 + DIRECT_DIAL_DISAMBIGUATION_TIMEOUT + 0.1) - texts = tts_calls(mock_tts) - # Digit 1 in idle menu → browse playlists - assert len(texts) > 0 - def test_direct_dial_known_number( self, mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path): """7-digit number matches phone book entry → plays that media.""" @@ -160,16 +151,12 @@ def test_direct_dial_known_number( mock_media_store.set_genres([]) session = make_session(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) - session.tick(now=DIAL_TONE_TIMEOUT_IDLE + 1.0) mock_media_client.calls.clear() - # Dial the number - t = 10.0 - digits = [int(c) for c in number] - session.handle_event((GpioEvent.DIGIT_DIALED, digits[0]), now=t) - session.handle_event((GpioEvent.DIGIT_DIALED, digits[1]), now=t + 0.05) - for d in digits[2:]: - t += 0.1 + # Dial from IDLE_DIAL_TONE (first non-zero digit enters DIRECT_DIAL) + t = 1.0 + for d in [int(c) for c in number]: session.handle_event((GpioEvent.DIGIT_DIALED, d), now=t) + t += 0.1 play_calls = [c for c in mock_media_client.calls if c[0] == 'play'] assert len(play_calls) >= 1 @@ -182,17 +169,12 @@ def test_direct_dial_unknown_number( mock_media_store.set_genres([]) session = make_session(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) - session.tick(now=DIAL_TONE_TIMEOUT_IDLE + 1.0) mock_tts.calls.clear() - # Dial unknown number: 1234567 - number = "1234567" - t = 10.0 - digits = [int(c) for c in number] - session.handle_event((GpioEvent.DIGIT_DIALED, digits[0]), now=t) - session.handle_event((GpioEvent.DIGIT_DIALED, digits[1]), now=t + 0.05) - for d in digits[2:]: - t += 0.1 + # Dial from IDLE_DIAL_TONE (first non-zero digit enters DIRECT_DIAL) + t = 1.0 + for d in [1, 2, 3, 4, 5, 6, 7]: session.handle_event((GpioEvent.DIGIT_DIALED, d), now=t) + t += 0.1 texts = tts_calls(mock_tts) assert any(SCRIPT_NOT_IN_SERVICE in txt for txt in texts) @@ -204,20 +186,19 @@ def test_direct_dial_ignores_digits_after_7( mock_media_store.set_genres([]) session = make_session(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) - session.tick(now=DIAL_TONE_TIMEOUT_IDLE + 1.0) - # Dial 8 digits: 12345678 - t = 10.0 - session.handle_event((GpioEvent.DIGIT_DIALED, 1), now=t) - session.handle_event((GpioEvent.DIGIT_DIALED, 2), now=t + 0.05) - for d in [3, 4, 5, 6, 7, 8]: # 8th digit - t += 0.1 + # Dial 7 digits from IDLE_DIAL_TONE (unknown number → not-in-service), then 1 more + # 8th digit should not trigger a second phone book lookup + t = 1.0 + for d in [1, 2, 3, 4, 5, 6, 7]: session.handle_event((GpioEvent.DIGIT_DIALED, d), now=t) - # lookup should only fire once (7 digits) - not_in_service_count = sum( - 1 for txt in tts_calls(mock_tts) - if "not in service" in txt.lower() - ) - assert not_in_service_count <= 1 + t += 0.1 + mock_tts.calls.clear() + # 8th digit: navigate IDLE_MENU option 1 (browse playlists — valid, no "not in service") + session.handle_event((GpioEvent.DIGIT_DIALED, 1), now=t) + # no additional "not in service" from an 8th phone book lookup + texts = tts_calls(mock_tts) + assert not any("not in service" in txt.lower() for txt in texts), \ + f"8th digit should not trigger a second lookup; got: {texts}" def test_direct_dial_hangup_before_7_digits( self, mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path): @@ -227,14 +208,13 @@ def test_direct_dial_hangup_before_7_digits( mock_media_store.set_genres([]) session = make_session(mock_audio, mock_tts, mock_media_client, mock_media_store, mock_error_queue, tmp_path) - session.tick(now=DIAL_TONE_TIMEOUT_IDLE + 1.0) mock_media_client.calls.clear() mock_tts.calls.clear() - # Start dialing 3 digits - t = 10.0 - session.handle_event((GpioEvent.DIGIT_DIALED, 1), now=t) - session.handle_event((GpioEvent.DIGIT_DIALED, 2), now=t + 0.05) - session.handle_event((GpioEvent.DIGIT_DIALED, 3), now=t + 0.1) + # Dial 3 digits from IDLE_DIAL_TONE, then hang up + t = 1.0 + for d in [1, 2, 3]: + session.handle_event((GpioEvent.DIGIT_DIALED, d), now=t) + t += 0.1 # Hang up before completing session.close() # No lookup or not-in-service @@ -343,3 +323,78 @@ def test_radio_is_still_optional( ) session = Session(menu=menu, now=0.0) assert session.menu._radio is None + + +# --------------------------------------------------------------------------- +# Session._run() error resilience (background thread) +# --------------------------------------------------------------------------- + +class TestSessionRunResilience: + """Session._run() catches exceptions and keeps the polling loop alive.""" + + def _make_menu(self, mock_audio, mock_tts, mock_media_client, mock_media_store, + mock_error_queue, tmp_path): + from src.menu import Menu + from src.phone_book import PhoneBook + from src.radio import MockRadio + return Menu( + audio=mock_audio, tts=mock_tts, + media_client=mock_media_client, media_store=mock_media_store, + phone_book=PhoneBook(db_path=str(tmp_path / "pb.db")), + error_queue=mock_error_queue, + radio=MockRadio(), + ) + + def test_gpio_drain_exception_does_not_kill_loop( + self, mock_audio, mock_tts, mock_media_client, mock_media_store, + mock_error_queue, tmp_path): + """RuntimeError from gpio.drain_digits() is caught; loop keeps running.""" + from src.session import Session + + call_count = [0] + + class FailOnceGpio: + def start(self): pass + def stop(self): pass + def drain_digits(self): + call_count[0] += 1 + if call_count[0] == 1: + raise RuntimeError("transient gpio error") + return [] + + menu = self._make_menu(mock_audio, mock_tts, mock_media_client, + mock_media_store, mock_error_queue, tmp_path) + session = Session(menu=menu, gpio=FailOnceGpio(), now=0.0) + session.start() + time.sleep(0.05) # ~10 iterations at 5 ms each + assert call_count[0] > 1, "loop should have continued past the exception" + session.close() + + def test_digit_delivered_after_prior_gpio_exception( + self, mock_audio, mock_tts, mock_media_client, mock_media_store, + mock_error_queue, tmp_path): + """After a gpio exception, subsequent drain_digits() digits still reach the menu.""" + from src.session import Session + import time as _time + + call_count = [0] + + class FailThenDigitGpio: + def start(self): pass + def stop(self): pass + def drain_digits(self): + call_count[0] += 1 + if call_count[0] == 1: + raise RuntimeError("transient error") + if call_count[0] == 2: + return [(5, _time.monotonic())] + return [] + + menu = self._make_menu(mock_audio, mock_tts, mock_media_client, + mock_media_store, mock_error_queue, tmp_path) + session = Session(menu=menu, gpio=FailThenDigitGpio(), now=0.0) + session.start() + time.sleep(0.05) + session.close() + # drain_digits was called at least twice → exception didn't kill the loop + assert call_count[0] >= 2 diff --git a/web/app.py b/web/app.py index 339a85e..c219b89 100644 --- a/web/app.py +++ b/web/app.py @@ -39,7 +39,7 @@ ("Overview", "README.md"), ("Installation", "INSTALL.md"), ("Amplifier", "docs/AMP_SETUP.md"), - ("Breakbeam Switch", "docs/BREAKBEAM_SETUP.md"), + ("Pulse Switch", "docs/PULSE_SWITCH_SETUP.md"), ("Hook Switch", "docs/HOOK_SWITCH_SETUP.md"), ("Piper TTS", "docs/PIPER_SETUP.md"), ]