From ef1ea0ce3427e9553fa77d2e121adf53197ac5e6 Mon Sep 17 00:00:00 2001 From: iffun Date: Wed, 29 Jul 2026 23:29:13 +0300 Subject: [PATCH 1/5] feat: add resilient multicamera target control --- README.md | 37 +- sony-tally/docs/ble-capabilities.md | 20 + sony-tally/docs/control-design-v2.md | 118 ++++++ sony-tally/src/main.cpp | 530 ++++++++++++++++++++------- 4 files changed, 568 insertions(+), 137 deletions(-) create mode 100644 sony-tally/docs/control-design-v2.md diff --git a/README.md b/README.md index cee4098..e502b2f 100644 --- a/README.md +++ b/README.md @@ -16,19 +16,30 @@ Alphaboop runs on a [LILYGO T-Display-S3](https://www.lilygo.cc/products/t-displ - `02 D5 20` — camera is recording; - `02 D5 00` — camera stopped recording. - Shows a red `REC` state and a local take timer while recording. +- Keeps the multi-camera dashboard alive when one camera disappears, marks that + slot `DISCONNECTED`, and reconnects it in the background when its BLE address + returns. +- Lets you arm any subset of a two-to-four-camera group. Connected cameras stay + visible even when they are not receiving commands. - Provides a two-button interface: - - **left**: start REC with a short press; stop with a hold; trigger AF-ON, photo, or C1 in their respective modes; - - **right**: short press selects the next item/mode; hold for 0.5 s cycles display brightness `100% → 50% → MIN`; at 1.5 s the full-screen power countdown begins. + - **left**: short press selects the next item/mode; hold for 0.5 s cycles display brightness `100% → 50% → MIN`; at 1.5 s the full-screen power countdown begins; + - **right**: confirm or run the selected action; in REC, tap starts the armed cameras and a hold stops the armed cameras. - Shows a large single-camera dashboard or dedicated layouts for two, three, and four cameras. -- Hold the **right** button for 3.5 seconds to sleep. It dims at 0.5 seconds, then uses a full-screen green `POWER OFF 2 → 1`; releasing it cancels. To wake, hold the **left** button for two seconds while the Alphaboop title assembles itself, then the dashboard appears. Reset is still reset. It has paperwork. +- Hold the **left** button for 3.5 seconds to sleep. It dims at 0.5 seconds, then uses a full-screen green `POWER OFF 2 → 1`; releasing it cancels. To wake, hold the **left** button for the unified two-second 8-bit camera animation. Cold boot gets the same production. Reset is still reset. It has paperwork. The camera remains the authority. It has earned this privilege. -## Next on the bench +## Choosing command targets -- **Active-camera selector:** keep every linked camera visible, but mark which - slots receive REC, AF-ON, photo, and C1 commands. “All linked” remains the - quick default. Sometimes one camera deserves a quiet moment. +For two to four cameras, cycle to `TARGETS`, then press the right button: + +1. Left moves through C1…Cn and `DONE`. +2. Right toggles the highlighted camera `ON`/`OFF`. +3. Select `DONE` and press right to return to REC. + +An armed camera keeps that setting through a temporary disconnect. Zero +targets is valid and sends nothing. Sometimes every camera deserves a quiet +moment. ## Hardware @@ -51,8 +62,8 @@ No extra LED or wiring is needed for the current display-tally prototype. The bo 1. On the camera, enable **Bluetooth Remote Control**. Sony moves menu labels between bodies and firmware versions, because stability is overrated. 2. Put the camera into Bluetooth remote pairing mode when Alphaboop asks for a connection. 3. Power Alphaboop. -4. Choose the number of camera slots with the right button, then confirm with the left button. -5. Wait for the device list, select a Sony camera with the right button, and connect with the left button. +4. Choose the number of camera slots with the left button, then confirm with the right button. +5. Wait for the device list, select a Sony camera with the left button, and connect with the right button. The board deliberately does **not** auto-connect on boot. A remote should not decide which camera is in charge of your shoot. @@ -103,6 +114,13 @@ For the full observed packet map and the important distinction between a button - The confirmed recording feedback is from an ILCE-7M4. Other camera bodies and firmware versions may differ. - Multi-camera operation is implemented but should be treated as **experimental** until verified with the exact number and model mix you plan to use. - BLE currently exposes reliable recording, focus-lock, and shutter-state notifications observed during testing. It does **not** provide confirmed AF/MF mode, exposure settings, timecode, card status, or live view. +- `FF02` is normally notification-only. Alphaboop attempts an initial and + periodic read only when a camera explicitly advertises read support. Until a + valid `02 D5` packet arrives, the UI shows an unknown REC state instead of + inventing standby. +- The take timer is local, not camera timecode. A `~` prefix (or `FROM LINK` on + the single-camera screen) means Alphaboop joined a recording already in + progress and can only count from the moment it learned about it. - Do not write random values to Sony's other BLE services. Some appear related to app pairing, Wi-Fi provisioning, or power. Curiosity is valuable. Camera recovery menus are less so. Wi-Fi is a separate future transport, not a secret BLE button. See [Wi-Fi feasibility](sony-tally/docs/wifi-feasibility.md). @@ -114,6 +132,7 @@ sony-tally/ src/main.cpp firmware include/TFTSetup.h T-Display-S3 display pin configuration docs/ble-capabilities.md verified BLE findings and command map + docs/control-design-v2.md two-button and reconnect behaviour contract docs/wifi-feasibility.md Wi-Fi research and boundaries tools/ serial diagnostics work/SonyBleProbe.swift macOS BLE probe; read-only by default diff --git a/sony-tally/docs/ble-capabilities.md b/sony-tally/docs/ble-capabilities.md index 84ec597..643a0e7 100644 --- a/sony-tally/docs/ble-capabilities.md +++ b/sony-tally/docs/ble-capabilities.md @@ -32,6 +32,26 @@ recording state changes, not merely an acknowledgement that a button command was sent. The tally firmware must always use this notification as its source of truth. +### Initial-state limitation + +`FF02` is normally advertised as `Notify`, not `Read`. Public implementations +subscribe to it; they do not expose a safe “tell me the current REC state” +command on `FF01`. Alphaboop checks the characteristic properties at runtime +and performs an initial/periodic read only if that particular camera explicitly +allows it. + +If no snapshot notification arrives after subscribing, the initial REC state +is unknown. This matters when the remote joins a camera that was already +recording: + +- the UI must show an unknown state until a `02 D5` packet arrives; +- a timer started after joining an existing recording is marked approximate; +- the real elapsed take duration and camera timecode cannot be reconstructed + from the FF remote service. + +The local timer is useful tally information. It is not camera timecode wearing +a small hat. + What this service does **not** expose in public research or our tests: - current AF/MF mode; diff --git a/sony-tally/docs/control-design-v2.md b/sony-tally/docs/control-design-v2.md new file mode 100644 index 0000000..6a3b797 --- /dev/null +++ b/sony-tally/docs/control-design-v2.md @@ -0,0 +1,118 @@ +# Alphaboop control design v2 + +This document is the implementation contract for the multi-camera controls. +The tested pre-change firmware is preserved by the +`v0.2.0-multicam-tested` Git tag. + +## Physical controls + +The labels follow the buttons' positions on the front of the T-Display-S3. + +| Context | Left button | Right button | +| --- | --- | --- | +| Camera count | Next option | Select | +| Camera discovery | Next device | Connect/select | +| Dashboard | Next control mode | Run selected action | +| REC mode | Next control mode | Tap to start armed cameras; hold to stop armed cameras | +| Any powered-on screen | Hold: DIM, then power-off countdown | Context action | +| Deep sleep | Hold for two seconds to wake | No action | + +The power gesture moves with the navigation role to the left button. This keeps +the right button unambiguously responsible for actions, including the REC +tap/hold distinction. + +## Selecting command targets + +All configured cameras remain visible. `armed` is independent of `connected`: + +- armed + connected: receives commands; +- unarmed + connected: monitored, but receives no command; +- armed + disconnected: remains selected and automatically receives commands + again after reconnecting; +- unarmed + disconnected: remains visible and quiet. + +For two to four camera slots, `TARGETS` appears in the ordinary mode cycle: + +1. Left cycles to `TARGETS`. +2. Right opens the target editor. +3. Left cycles through C1…Cn and `DONE`. +4. Right toggles the highlighted camera, or confirms `DONE`. + +Zero armed cameras is allowed. An action then reports `NO TARGETS` and sends +nothing. The default after camera selection is all cameras armed. + +## REC semantics + +REC is not sent as a blind group toggle: + +- a short right press sends REC only to armed, connected cameras whose known + state is not recording; +- a held right press sends REC only to armed, connected cameras whose known + state is recording. + +`FF02 / 02 D5` remains the source of truth. A command being transmitted does +not make the UI red. + +## Disconnect and reconnect + +After the initial set of slots has reached the dashboard, the dashboard is +sticky. Losing one camera never returns the whole device to discovery. + +- The disconnected row becomes grey and says `DISCONNECTED`. +- Other cameras remain controllable. +- Background scanning looks for the saved BLE address. +- Reconnection is serialized and rate-limited. +- A recovered camera returns to the same slot and preserves its armed state. + +## Initial recording state and timer limits + +The firmware subscribes to `FF02` and also attempts an initial/periodic read +only when the camera advertises the characteristic as readable. + +Sony's public FF remote profile is normally notification-only. It provides +recording state changes but no query command and no camera timecode. Therefore: + +- a notification received after connection can establish the current REC + state; +- if the camera does not emit a snapshot, the initial state remains unknown; +- a take started by Alphaboop gets an exact local elapsed timer; +- a take already running before connection can only get an approximate local + timer beginning at the first observed REC notification; +- the actual camera timecode requires a different control plane such as the + supported Wi-Fi/Camera Remote SDK path. + +The UI must never imply that a locally counted timer is camera timecode. + +## Unified startup animation + +Cold boot and deep-sleep wake use the same two-second, code-rendered 8-bit +sequence: + +1. a limited-palette star field wakes up; +2. a pixel camera assembles in the centre; +3. the lens receives one restrained orange “boop” pulse; +4. the pixels resolve into `ALPHABOOP` and `by @iffun`. + +Deep-sleep wake requires the left button to remain held for the whole +animation. Cold boot plays the same sequence without the hold requirement. +Frames are rendered through a TFT sprite so animation does not depend on +full-screen clears visible to the user. + +## Hardware acceptance checklist + +Run this before merging the branch into the stable release: + +1. Cold boot and deep-sleep wake show the same animation; an early release + during wake returns to sleep. +2. On the count and discovery screens, left moves and right confirms. +3. Connect two or more cameras and use `TARGETS` to arm only C1, then C1+C3, + then none, then all. +4. Verify REC start/stop and C1 are sent only to armed slots. +5. Turn off one camera while another is recording. The dashboard and remaining + controls must stay alive; the lost slot must say `DISCONNECTED`. +6. Turn that camera back on. It must reconnect into its original slot without + changing its armed state. +7. Connect Alphaboop while a camera is already recording. Record whether FF02 + provides a snapshot, is readable, or remains unknown; keep the serial log. +8. Hold left through DIM and cancel the power countdown, then complete it. + Right must never change brightness. diff --git a/sony-tally/src/main.cpp b/sony-tally/src/main.cpp index 068ed49..b6aa69f 100644 --- a/sony-tally/src/main.cpp +++ b/sony-tally/src/main.cpp @@ -8,8 +8,8 @@ #include #include -constexpr int ENTER_BUTTON = 0; // physical left -constexpr int NEXT_BUTTON = 14; // physical right +constexpr int SELECT_BUTTON = 0; // physical left: next/mode, hold for dim/power +constexpr int ACTION_BUTTON = 14; // physical right: select/run, hold to stop REC constexpr int PERIPHERAL_POWER = 15; // required to power LCD/peripherals from battery constexpr int SINGLE_LEFT = 6; constexpr uint8_t MAX_CAMERAS = 4; @@ -18,6 +18,8 @@ constexpr uint32_t BRIGHTNESS_HOLD_MS = 500; constexpr uint32_t POWER_COUNTDOWN_START_MS = 1500; constexpr uint32_t POWER_OFF_HOLD_MS = 3500; constexpr uint32_t WAKE_CONFIRM_HOLD_MS = 2000; +constexpr uint32_t RECONNECT_COOLDOWN_MS = 5000; +constexpr uint32_t STATUS_READ_INTERVAL_MS = 5000; // The T-Display-S3's backlight driver is not PWM: it changes between its // sixteen hardware levels by counting rising edges on GPIO38. This mirrors // LilyGO's factory firmware. An ordinary PWM signal occasionally lands on a @@ -28,18 +30,19 @@ constexpr const char* BRIGHTNESS_LABELS[] = {"100%", "50%", "MIN"}; const BLEUUID SONY_SERVICE("8000FF00-FF00-FFFF-FFFF-FFFFFFFFFFFF"); const BLEUUID FF01((uint16_t)0xFF01), FF02((uint16_t)0xFF02); -enum class ControlMode { REC, AF_ON, PHOTO, C1 }; +enum class ControlMode { REC, TARGETS, AF_ON, PHOTO, C1 }; enum class PhotoStep { IDLE, FULL_DOWN, HALF_UP, FULL_UP }; -enum class UiState { SELECT_CAMERA_COUNT, SELECT_CAMERAS, DASHBOARD }; -enum class ScreenView { NONE, CAMERA_COUNT, CAMERA_LIST, SINGLE_DASHBOARD, MULTI_DASHBOARD }; +enum class UiState { SELECT_CAMERA_COUNT, SELECT_CAMERAS, TARGET_EDITOR, DASHBOARD }; +enum class ScreenView { NONE, CAMERA_COUNT, CAMERA_LIST, TARGET_EDITOR, SINGLE_DASHBOARD, MULTI_DASHBOARD }; struct Device { String name; String address; bool sony; bool pairingReady; + uint32_t lastSeen; Device(const String& deviceName, const String& deviceAddress, bool isSony, bool isPairingReady) - : name(deviceName), address(deviceAddress), sony(isSony), pairingReady(isPairingReady) {} + : name(deviceName), address(deviceAddress), sony(isSony), pairingReady(isPairingReady), lastSeen(millis()) {} }; // Everything that can differ between C1, C2, C3 and C4 lives here. The user @@ -51,6 +54,7 @@ struct Camera { BLERemoteCharacteristic* command = nullptr; BLERemoteCharacteristic* notify = nullptr; bool desired = false; + bool armed = true; bool transportConnected = false; bool securityComplete = false; bool securityOK = false; @@ -58,6 +62,9 @@ struct Camera { bool ready = false; bool pairingFailed = false; bool recording = false; + bool recordingKnown = false; + bool recTimerApproximate = false; + bool notifyReadable = false; bool focusKnown = false; bool focusLocked = false; bool shutterActive = false; @@ -65,6 +72,9 @@ struct Camera { uint32_t takeCount = 0; uint32_t lastTakeSeconds = 0; uint32_t sessionSeconds = 0; + uint32_t lastReconnectAttempt = 0; + uint32_t lastStatusRead = 0; + uint32_t lastRecordCommandAt = 0; bool buttonReleasePending = false; uint8_t pendingReleaseCode = 0; String pendingReleaseLabel; @@ -87,19 +97,21 @@ String status = "SCANNING..."; ControlMode controlMode = ControlMode::REC; UiState uiState = UiState::SELECT_CAMERA_COUNT; uint8_t targetCameras = 1; -bool enterHeld = false, enterLongHandled = false; -uint32_t enterPressedAt = 0; -bool nextHeld = false, nextLongHandled = false; -uint32_t nextPressedAt = 0; +bool actionHeld = false, actionLongHandled = false; +uint32_t actionPressedAt = 0; +bool selectHeld = false, selectLongHandled = false; +uint32_t selectPressedAt = 0; bool powerOffCountdownActive = false; uint8_t powerOffSecondsShown = 0; bool inputsArmed = false; uint32_t inputsReleasedAt = 0; uint8_t brightnessIndex = 0; uint32_t brightnessToastUntil = 0; +uint8_t targetEditorCursor = 0; +uint8_t statusReadCursor = 0; struct Key { int pin; bool raw, stable; uint32_t changed; }; -Key enter{ENTER_BUTTON, true, true, 0}, next{NEXT_BUTTON, true, true, 0}; +Key selectKey{SELECT_BUTTON, true, true, 0}, actionKey{ACTION_BUTTON, true, true, 0}; void drawBrightnessToast(); @@ -124,13 +136,29 @@ bool allCamerasLinked() { } bool anyRecording() { - for (uint8_t i = 0; i < targetCameras; ++i) if (cameras[i].ready && cameras[i].recording) return true; + for (uint8_t i = 0; i < targetCameras; ++i) { + if (cameras[i].ready && cameras[i].recordingKnown && cameras[i].recording) return true; + } + return false; +} + +bool anyArmedRecording() { + for (uint8_t i = 0; i < targetCameras; ++i) { + if (cameras[i].armed && cameras[i].ready && cameras[i].recordingKnown && cameras[i].recording) return true; + } return false; } +uint8_t armedCameraCount() { + uint8_t result = 0; + for (uint8_t i = 0; i < targetCameras; ++i) if (cameras[i].armed) ++result; + return result; +} + const char* controlModeName() { switch (controlMode) { case ControlMode::REC: return "REC"; + case ControlMode::TARGETS: return "TARGETS"; case ControlMode::AF_ON: return "AF-ON"; case ControlMode::PHOTO: return "PHOTO"; case ControlMode::C1: return "C1"; @@ -138,8 +166,12 @@ const char* controlModeName() { return "?"; } -const char* leftButtonLabel() { - if (controlMode == ControlMode::REC) return anyRecording() ? "HOLD STOP" : targetCameras == 1 ? "REC" : "REC ALL"; +const char* actionButtonLabel() { + if (controlMode == ControlMode::REC) { + if (!armedCameraCount()) return "NO TARGETS"; + return anyArmedRecording() ? "HOLD STOP" : targetCameras == 1 ? "REC" : "REC ARMED"; + } + if (controlMode == ControlMode::TARGETS) return "EDIT"; if (controlMode == ControlMode::C1) return "C1"; return controlModeName(); } @@ -176,10 +208,10 @@ void drawCountSelectionScreen() { tft.drawString("RESTART TO CHANGE", 8, 256, 2); tft.setTextColor(TFT_ORANGE, TFT_BLACK); tft.setTextDatum(BL_DATUM); - tft.drawString("SELECT", 5, 313, 2); + tft.drawString("NEXT", 5, 313, 2); tft.setTextColor(TFT_CYAN, TFT_BLACK); tft.setTextDatum(BR_DATUM); - tft.drawString("NEXT", 165, 313, 2); + tft.drawString("SELECT", 165, 313, 2); tft.setTextDatum(TL_DATUM); } @@ -216,10 +248,46 @@ void drawSelectionScreen() { drawSelectionBody(); tft.setTextColor(TFT_ORANGE, TFT_BLACK); tft.setTextDatum(BL_DATUM); - tft.drawString("SELECT", 5, 313, 2); + tft.drawString("NEXT", 5, 313, 2); + tft.setTextColor(TFT_CYAN, TFT_BLACK); + tft.setTextDatum(BR_DATUM); + tft.drawString("CONNECT", 165, 313, 2); + tft.setTextDatum(TL_DATUM); +} + +void drawTargetEditor() { + tft.setTextColor(TFT_GREEN, TFT_BLACK); + tft.drawString("COMMAND TARGETS", 7, 9, 2); + tft.setTextColor(TFT_DARKGREY, TFT_BLACK); + tft.drawString("CONNECTED OR NOT", 7, 29, 2); + + for (uint8_t i = 0; i < targetCameras; ++i) { + const Camera& camera = cameras[i]; + const int y = 59 + i * 42; + const bool cursor = targetEditorCursor == i; + const uint16_t stateColor = camera.ready ? TFT_WHITE : TFT_DARKGREY; + tft.drawRoundRect(4, y, 162, 35, 5, cursor ? TFT_GREEN : TFT_DARKGREY); + tft.setTextColor(cursor ? TFT_GREEN : TFT_LIGHTGREY, TFT_BLACK); + tft.drawString(cursor ? ">" : " ", 9, y + 9, 2); + tft.setTextColor(camera.armed ? TFT_ORANGE : TFT_DARKGREY, TFT_BLACK); + tft.drawString(camera.armed ? "ON" : "OFF", 24, y + 9, 2); + tft.setTextColor(stateColor, TFT_BLACK); + tft.drawString(String("C") + (i + 1), 54, y + 9, 2); + tft.setTextDatum(TR_DATUM); + tft.drawString(compactName(camera.name, 12), 158, y + 9, 2); + tft.setTextDatum(TL_DATUM); + } + + const int doneY = 59 + targetCameras * 42 + 5; + const bool doneSelected = targetEditorCursor == targetCameras; + tft.setTextColor(doneSelected ? TFT_GREEN : TFT_LIGHTGREY, TFT_BLACK); + tft.drawString(doneSelected ? "> DONE" : " DONE", 9, doneY, 4); + tft.setTextColor(TFT_ORANGE, TFT_BLACK); + tft.setTextDatum(BL_DATUM); + tft.drawString("NEXT", 5, 313, 2); tft.setTextColor(TFT_CYAN, TFT_BLACK); tft.setTextDatum(BR_DATUM); - tft.drawString("NEXT", 165, 313, 2); + tft.drawString(doneSelected ? "DONE" : "TOGGLE", 165, 313, 2); tft.setTextDatum(TL_DATUM); } @@ -252,14 +320,23 @@ void drawCameraRow(uint8_t index) { // Colour is the whole state vocabulary here: red = recording, white = // ready/standby, grey = the camera has not linked. No redundant STBY text. uint16_t color = camera.recording ? TFT_RED : camera.ready ? TFT_WHITE : TFT_DARKGREY; - tft.drawRoundRect(3, y, 164, height, 6, camera.recording ? TFT_RED : TFT_DARKGREY); + uint16_t border = camera.recording ? TFT_RED : camera.armed ? TFT_ORANGE : TFT_DARKGREY; + tft.drawRoundRect(3, y, 164, height, 6, border); + tft.fillRect(9, y + 8, 5, 5, camera.armed ? TFT_ORANGE : TFT_DARKGREY); tft.setTextColor(color, TFT_BLACK); - tft.drawString(String("C") + (index + 1), 10, y + 7, 2); + tft.drawString(String("C") + (index + 1), 18, y + 7, 2); tft.setTextColor(color, TFT_BLACK); tft.setTextDatum(TR_DATUM); tft.drawString(compactName(cameraName(camera), targetCameras == 2 ? 19 : 16), 159, y + 7, 2); tft.setTextDatum(MC_DATUM); - tft.drawString(timer, 85, y + (height / 2) + 8, timerFont); + if (!camera.ready) { + tft.drawString("DISCONNECTED", 85, y + (height / 2) + 8, 2); + } else if (!camera.recordingKnown) { + tft.drawString("REC STATUS ?", 85, y + (height / 2) + 8, 2); + } else { + tft.drawString(String(camera.recTimerApproximate && camera.recording ? "~" : "") + timer, + 85, y + (height / 2) + 8, timerFont); + } tft.setTextDatum(TL_DATUM); } @@ -272,7 +349,13 @@ void drawSingleTimer(const Camera& camera) { uint32_t seconds = camera.recording ? (millis() - camera.recStarted) / 1000 : camera.lastTakeSeconds; formatTime(seconds, timer, sizeof(timer)); tft.setTextColor(camera.recording ? TFT_RED : TFT_WHITE, TFT_BLACK); - tft.drawString(timer, SINGLE_LEFT, 96, 7); + if (!camera.ready) { + tft.setTextColor(TFT_DARKGREY, TFT_BLACK); + tft.drawString("OFFLINE", SINGLE_LEFT, 105, 4); + } else if (!camera.recordingKnown) { + tft.setTextColor(TFT_LIGHTGREY, TFT_BLACK); + tft.drawString("REC ?", SINGLE_LEFT, 105, 4); + } else tft.drawString(timer, SINGLE_LEFT, 96, 7); } void drawSingleSummary(const Camera& camera) { @@ -294,6 +377,11 @@ void drawSingleRecordState(const Camera& camera) { tft.fillCircle(14, 68, 9, TFT_RED); tft.setTextColor(TFT_RED, TFT_BLACK); tft.drawString("REC", 30, 68, 4); + if (camera.recTimerApproximate) { + tft.setTextColor(TFT_ORANGE, TFT_BLACK); + tft.setTextDatum(MR_DATUM); + tft.drawString("FROM LINK", 165, 68, 2); + } tft.setTextDatum(TL_DATUM); } @@ -307,10 +395,10 @@ void drawDashboardFooter() { tft.fillRect(0, 300, 170, 20, TFT_BLACK); tft.setTextColor(TFT_ORANGE, TFT_BLACK); tft.setTextDatum(BL_DATUM); - tft.drawString(leftButtonLabel(), 5, 313, 2); + tft.drawString("MODE", 5, 313, 2); tft.setTextColor(TFT_CYAN, TFT_BLACK); tft.setTextDatum(BR_DATUM); - tft.drawString("MODE", 165, 313, 2); + tft.drawString(actionButtonLabel(), 165, 313, 2); tft.setTextDatum(TL_DATUM); } @@ -335,14 +423,14 @@ void drawMultiDashboard() { tft.setTextDatum(TL_DATUM); for (uint8_t i = 0; i < targetCameras; ++i) drawCameraRow(i); tft.setTextColor(TFT_CYAN, TFT_BLACK); - tft.drawString(String("MODE: ") + controlModeName() + " ALL LINKED", 8, 265, 2); + tft.drawString(String("MODE: ") + controlModeName() + " " + armedCameraCount() + " ARMED", 8, 265, 2); drawDashboardFooter(); } void refreshRecordingDisplay() { // Called once a second only while at least one camera records. Do not call // redraw(): that clears the entire TFT and produces a visible flash. - if (!allCamerasLinked()) return; + if (uiState != UiState::DASHBOARD) return; if (targetCameras == 1) { drawSingleTimer(cameras[0]); drawSingleSummary(cameras[0]); @@ -360,7 +448,8 @@ void refreshRecordingDisplay() { ScreenView currentScreenView() { if (uiState == UiState::SELECT_CAMERA_COUNT) return ScreenView::CAMERA_COUNT; - if (!allCamerasLinked()) return ScreenView::CAMERA_LIST; + if (uiState == UiState::SELECT_CAMERAS) return ScreenView::CAMERA_LIST; + if (uiState == UiState::TARGET_EDITOR) return ScreenView::TARGET_EDITOR; return targetCameras == 1 ? ScreenView::SINGLE_DASHBOARD : ScreenView::MULTI_DASHBOARD; } @@ -379,6 +468,7 @@ void redraw() { renderedView = currentScreenView(); if (renderedView == ScreenView::CAMERA_COUNT) drawCountSelectionScreen(); else if (renderedView == ScreenView::CAMERA_LIST) drawSelectionScreen(); + else if (renderedView == ScreenView::TARGET_EDITOR) drawTargetEditor(); else if (renderedView == ScreenView::SINGLE_DASHBOARD) drawSingleDashboard(); else if (renderedView == ScreenView::MULTI_DASHBOARD) drawMultiDashboard(); drawBrightnessToast(); @@ -399,6 +489,9 @@ void refreshCurrentScreen() { } else if (view == ScreenView::CAMERA_LIST) { tft.fillRect(0, 20, 170, 250, TFT_BLACK); drawSelectionBody(); + } else if (view == ScreenView::TARGET_EDITOR) { + tft.fillRect(0, 50, 170, 250, TFT_BLACK); + drawTargetEditor(); } else if (view == ScreenView::SINGLE_DASHBOARD) { const Camera& camera = cameras[0]; drawSingleRecordState(camera); @@ -411,7 +504,7 @@ void refreshCurrentScreen() { for (uint8_t i = 0; i < targetCameras; ++i) drawCameraRow(i); tft.fillRect(0, 260, 170, 24, TFT_BLACK); tft.setTextColor(TFT_CYAN, TFT_BLACK); - tft.drawString(String("MODE: ") + controlModeName() + " ALL LINKED", 8, 265, 2); + tft.drawString(String("MODE: ") + controlModeName() + " " + armedCameraCount() + " ARMED", 8, 265, 2); drawDashboardFooter(); } drawBrightnessToast(); @@ -440,41 +533,74 @@ void restoreAfterPowerCountdown() { dirty = false; } -void drawBootAnimation() { - // A short, deliberately restrained hello. The device has work to do. - tft.setTextDatum(MC_DATUM); - for (uint8_t frame = 0; frame < 4; ++frame) { - tft.fillScreen(TFT_BLACK); - tft.setTextColor(frame == 3 ? TFT_GREEN : TFT_DARKGREY, TFT_BLACK); - tft.drawString("ALPHABOOP", 85, 150 - frame * 13, 4); - delay(110); +bool playStartupAnimation(bool requireLeftHold) { + // Cold boot and wake get the same small 8-bit production. Rendering into a + // sprite keeps the TFT from showing the black clear between frames. + TFT_eSprite frame(&tft); + frame.setColorDepth(8); + if (!frame.createSprite(170, 320)) { + Serial.println("DISPLAY: startup sprite allocation failed"); + return !requireLeftHold || digitalRead(SELECT_BUTTON) == LOW; } - tft.setTextDatum(TL_DATUM); -} -bool playWakeHoldAnimation() { - // The two-second hold is visible work, not an invisible toll booth. Letters - // arrive one by one while LEFT remains pressed; releasing it returns to - // sleep without starting Bluetooth or the dashboard. - constexpr const char* title = "ALPHABOOP"; - constexpr uint8_t titleLength = 9; - const uint32_t startedAt = millis(); - uint8_t lettersDrawn = 0; - tft.fillScreen(TFT_BLACK); - tft.setTextColor(TFT_GREEN, TFT_BLACK); - tft.setTextDatum(TL_DATUM); + constexpr uint8_t starX[] = {8, 27, 49, 73, 97, 121, 148, 161, 18, 62, 112, 139}; + constexpr uint8_t starY[] = {18, 61, 107, 34, 82, 15, 119, 52, 151, 137, 174, 202}; + constexpr uint8_t frames = 40; + constexpr uint16_t frameMs = WAKE_CONFIRM_HOLD_MS / frames; - while (millis() - startedAt < WAKE_CONFIRM_HOLD_MS) { - if (digitalRead(ENTER_BUTTON) != LOW) return false; - uint8_t wanted = 1 + ((millis() - startedAt) * titleLength) / 1650; - if (wanted > titleLength) wanted = titleLength; - if (wanted != lettersDrawn) { - lettersDrawn = wanted; - tft.drawString(String(title).substring(0, lettersDrawn), 8, 130, 4); + for (uint8_t f = 0; f < frames; ++f) { + if (requireLeftHold && digitalRead(SELECT_BUTTON) != LOW) { + frame.deleteSprite(); + return false; } - delay(5); + frame.fillSprite(TFT_BLACK); + + // A sparse star field, because even boot screens need somewhere to work. + for (uint8_t i = 0; i < sizeof(starX); ++i) { + const int y = (starY[i] + f * (1 + i % 3) * 2) % 250; + const uint16_t color = (i + f) % 5 == 0 ? TFT_ORANGE : + (i + f) % 2 == 0 ? TFT_CYAN : TFT_DARKGREY; + frame.fillRect(starX[i], y, (i % 4 == 0) ? 3 : 2, (i % 4 == 0) ? 3 : 2, color); + } + + if (f < 28) { + const int assembled = min((int)f, 14); + const int left = 27 + (14 - assembled) * 3; + const int right = 143 - (14 - assembled) * 3; + if (left < right) { + frame.drawRoundRect(left, 112, right - left, 73, 4, TFT_LIGHTGREY); + frame.fillRect(left + 10, 101, min(43, max(0, assembled * 4)), 12, TFT_LIGHTGREY); + frame.fillRect(right - 24, 103, min(16, max(0, assembled * 2)), 8, TFT_ORANGE); + } + if (f >= 7) { + const int radius = min(29, 5 + (f - 7) * 2); + frame.drawCircle(85, 149, radius, TFT_CYAN); + if (radius > 12) frame.drawCircle(85, 149, radius - 9, TFT_LIGHTGREY); + } + if (f >= 18) { + const int pulse = 7 + abs(22 - f) * 3; + frame.drawCircle(85, 149, min(pulse, 36), TFT_ORANGE); + frame.fillCircle(85, 149, 4, TFT_ORANGE); + } + frame.setTextDatum(MC_DATUM); + frame.setTextColor(TFT_DARKGREY, TFT_BLACK); + frame.drawString("INITIALISING REALITY", 85, 234, 2); + } else { + const uint8_t reveal = min((int)9, 1 + (f - 28)); + frame.setTextDatum(MC_DATUM); + frame.setTextColor(TFT_WHITE, TFT_BLACK); + frame.drawString(String("ALPHABOOP").substring(0, reveal), 85, 138, 4); + frame.setTextColor(TFT_ORANGE, TFT_BLACK); + if (f >= 34) frame.drawString("by @iffun", 85, 172, 2); + frame.drawFastHLine(35, 199, min(100, (f - 28) * 10), TFT_GREEN); + } + + frame.pushSprite(0, 0); + delay(frameMs); } - return digitalRead(ENTER_BUTTON) == LOW; + frame.deleteSprite(); + tft.setTextDatum(TL_DATUM); + return !requireLeftHold || digitalRead(SELECT_BUTTON) == LOW; } [[noreturn]] void enterDeepSleep() { @@ -487,11 +613,11 @@ bool playWakeHoldAnimation() { // INPUT_PULLUP belongs to the digital GPIO peripheral and does not remain // reliable in deep sleep. GPIO0 then floats and politely wakes us over and // over. Keep its RTC pull-up enabled instead; LEFT is active-low. - rtc_gpio_init(static_cast(ENTER_BUTTON)); - rtc_gpio_set_direction(static_cast(ENTER_BUTTON), RTC_GPIO_MODE_INPUT_ONLY); - rtc_gpio_pullup_en(static_cast(ENTER_BUTTON)); - rtc_gpio_pulldown_dis(static_cast(ENTER_BUTTON)); - esp_sleep_enable_ext0_wakeup(static_cast(ENTER_BUTTON), 0); + rtc_gpio_init(static_cast(SELECT_BUTTON)); + rtc_gpio_set_direction(static_cast(SELECT_BUTTON), RTC_GPIO_MODE_INPUT_ONLY); + rtc_gpio_pullup_en(static_cast(SELECT_BUTTON)); + rtc_gpio_pulldown_dis(static_cast(SELECT_BUTTON)); + esp_sleep_enable_ext0_wakeup(static_cast(SELECT_BUTTON), 0); esp_deep_sleep_start(); while (true) delay(1000); } @@ -501,21 +627,26 @@ int cameraIndexForNotify(BLERemoteCharacteristic* characteristic) { return -1; } -void statusNotify(BLERemoteCharacteristic* characteristic, uint8_t* data, size_t length, bool) { - int index = cameraIndexForNotify(characteristic); - if (index < 0 || length != 3 || data[0] != 0x02) return; +void applyStatusPacket(uint8_t index, const uint8_t* data, size_t length, const char* source) { + if (index >= targetCameras || length != 3 || data[0] != 0x02) return; Camera& camera = cameras[index]; - Serial.printf("C%d FF02: %02X %02X %02X\n", index + 1, data[0], data[1], data[2]); + Serial.printf("C%d FF02 %s: %02X %02X %02X\n", index + 1, source, data[0], data[1], data[2]); if (data[1] == 0xD5) { - bool nowRecording = data[2] == 0x20; - if (nowRecording && !camera.recording) { + const bool nowRecording = data[2] == 0x20; + const bool wasKnown = camera.recordingKnown; + const bool wasRecording = camera.recording; + if (nowRecording && (!wasKnown || !wasRecording)) { camera.recStarted = millis(); + camera.recTimerApproximate = !camera.lastRecordCommandAt || + millis() - camera.lastRecordCommandAt > 3000; ++camera.takeCount; - } else if (!nowRecording && camera.recording) { + } else if (!nowRecording && wasKnown && wasRecording) { camera.lastTakeSeconds = (millis() - camera.recStarted) / 1000; camera.sessionSeconds += camera.lastTakeSeconds; + camera.recTimerApproximate = false; } camera.recording = nowRecording; + camera.recordingKnown = true; } else if (data[1] == 0x3F) { camera.focusKnown = true; camera.focusLocked = data[2] == 0x20; @@ -525,6 +656,25 @@ void statusNotify(BLERemoteCharacteristic* characteristic, uint8_t* data, size_t dirty = true; } +void statusNotify(BLERemoteCharacteristic* characteristic, uint8_t* data, size_t length, bool) { + const int index = cameraIndexForNotify(characteristic); + if (index < 0) return; + applyStatusPacket(index, data, length, "NOTIFY"); +} + +bool readCameraStatus(uint8_t index, const char* source) { + Camera& camera = cameras[index]; + if (!camera.ready || !camera.notify || !camera.notifyReadable) return false; + camera.lastStatusRead = millis(); + const std::string value = camera.notify->readValue(); + if (value.size() != 3) { + Serial.printf("C%d FF02 %s: read returned %u bytes\n", index + 1, source, (unsigned)value.size()); + return false; + } + applyStatusPacket(index, reinterpret_cast(value.data()), value.size(), source); + return true; +} + void saveCameraSlot(uint8_t index) { String key = String("camera") + index; preferences.putString(key.c_str(), cameras[index].address); @@ -574,6 +724,14 @@ class ClientEvents : public BLEClientCallbacks { camera.serviceDiscoveryStarted = false; camera.command = nullptr; camera.notify = nullptr; + camera.notifyReadable = false; + camera.recording = false; + camera.recordingKnown = false; + camera.recTimerApproximate = false; + camera.lastReconnectAttempt = millis(); + camera.focusKnown = false; + camera.focusLocked = false; + camera.shutterActive = false; camera.buttonReleasePending = false; camera.photoStep = PhotoStep::IDLE; if (connectingSlot == slot_) connectingSlot = -1; @@ -598,7 +756,6 @@ bool sonyPairingEnabled(const std::string& data) { class Scanner : public BLEAdvertisedDeviceCallbacks { void onResult(BLEAdvertisedDevice advertised) override { - if (devices.size() >= 32) return; String address(advertised.getAddress().toString().c_str()); String name = advertised.haveName() ? String(advertised.getName().c_str()) : String(); std::string manufacturer = advertised.haveManufacturerData() ? advertised.getManufacturerData() : std::string(); @@ -612,9 +769,11 @@ class Scanner : public BLEAdvertisedDeviceCallbacks { device.name = name.isEmpty() ? device.name : name; device.sony = isSony; device.pairingReady = pairingReady; + device.lastSeen = millis(); if (changed) dirty = true; return; } + if (devices.size() >= 32) return; if (name.isEmpty()) name = "SONY CAMERA"; if (isSony) { devices.insert(devices.begin(), {name, address, true, pairingReady}); selected = 0; } else devices.push_back({name, address, false, false}); @@ -658,11 +817,21 @@ bool discoverSonyService(uint8_t index) { camera.client->disconnect(); return false; } - camera.notify->registerForNotify(statusNotify); + camera.recordingKnown = false; + camera.notifyReadable = camera.notify->canRead(); camera.ready = true; + camera.notify->registerForNotify(statusNotify); if (connectingSlot == index) connectingSlot = -1; status = String("C") + (index + 1) + " ALPHABOOP LINK"; - Serial.printf("C%d SONY: FF01/FF02 ready\n", index + 1); + Serial.printf("C%d SONY: FF01/FF02 ready; FF02 read=%d notify=%d\n", index + 1, + camera.notifyReadable, camera.notify->canNotify()); + if (camera.notifyReadable) readCameraStatus(index, "INITIAL READ"); + else Serial.printf("C%d FF02: notify-only; waiting for camera truth\n", index + 1); + if (uiState == UiState::SELECT_CAMERAS && allCamerasLinked()) { + uiState = UiState::DASHBOARD; + controlMode = ControlMode::REC; + Serial.println("UI: initial camera set ready; dashboard locked in"); + } dirty = true; return true; } @@ -675,9 +844,10 @@ bool sendSonyCommand(Camera& camera, const char* label, uint8_t code) { return true; } -void sendMomentaryButtonAll(const char* label, uint8_t downCode, uint8_t upCode) { +void sendMomentaryButtonArmed(const char* label, uint8_t downCode, uint8_t upCode) { for (uint8_t i = 0; i < targetCameras; ++i) { Camera& camera = cameras[i]; + if (!camera.armed) continue; if (!sendSonyCommand(camera, label, downCode)) continue; camera.pendingReleaseCode = upCode; camera.pendingReleaseLabel = label; @@ -686,14 +856,33 @@ void sendMomentaryButtonAll(const char* label, uint8_t downCode, uint8_t upCode) } } -void sendRecordClickAll(const char* label) { - Serial.printf("REC ALL: %s\n", label); - sendMomentaryButtonAll("REC BUTTON DOWN", 0x0F, 0x0E); +void sendRecordClickArmed(bool start, const char* label) { + uint8_t sent = 0; + Serial.printf("REC ARMED: %s\n", label); + for (uint8_t i = 0; i < targetCameras; ++i) { + Camera& camera = cameras[i]; + if (!camera.armed || !camera.ready) continue; + const bool eligible = start ? (!camera.recordingKnown || !camera.recording) + : (camera.recordingKnown && camera.recording); + if (!eligible || !sendSonyCommand(camera, "REC BUTTON DOWN", 0x0F)) continue; + camera.lastRecordCommandAt = millis(); + camera.pendingReleaseCode = 0x0E; + camera.pendingReleaseLabel = "REC BUTTON"; + camera.buttonReleasePending = true; + camera.buttonReleaseAt = millis() + 80; + ++sent; + } + if (!sent) { + status = !armedCameraCount() ? "NO TARGETS" : start ? "NO CAMERA TO START" : "NO CAMERA TO STOP"; + Serial.printf("REC ARMED: nothing sent (%s)\n", status.c_str()); + dirty = true; + } } -void startPhotoSequenceAll() { +void startPhotoSequenceArmed() { for (uint8_t i = 0; i < targetCameras; ++i) { Camera& camera = cameras[i]; + if (!camera.armed) continue; if (!sendSonyCommand(camera, "PHOTO HALF DOWN", 0x07)) continue; camera.photoStep = PhotoStep::FULL_DOWN; camera.photoStepAt = millis() + 250; @@ -702,15 +891,21 @@ void startPhotoSequenceAll() { void runSelectedAction() { switch (controlMode) { - case ControlMode::REC: sendRecordClickAll(anyRecording() ? "STOP" : "START"); break; - case ControlMode::AF_ON: sendMomentaryButtonAll("AF-ON", 0x15, 0x14); break; - case ControlMode::PHOTO: startPhotoSequenceAll(); break; - case ControlMode::C1: sendMomentaryButtonAll("C1", 0x21, 0x20); break; + case ControlMode::REC: sendRecordClickArmed(false, "STOP"); break; + case ControlMode::TARGETS: + uiState = UiState::TARGET_EDITOR; + targetEditorCursor = 0; + dirty = true; + break; + case ControlMode::AF_ON: sendMomentaryButtonArmed("AF-ON", 0x15, 0x14); break; + case ControlMode::PHOTO: startPhotoSequenceArmed(); break; + case ControlMode::C1: sendMomentaryButtonArmed("C1", 0x21, 0x20); break; } } void nextControlMode() { - controlMode = controlMode == ControlMode::REC ? ControlMode::AF_ON : + controlMode = controlMode == ControlMode::REC ? (targetCameras > 1 ? ControlMode::TARGETS : ControlMode::AF_ON) : + controlMode == ControlMode::TARGETS ? ControlMode::AF_ON : controlMode == ControlMode::AF_ON ? ControlMode::PHOTO : controlMode == ControlMode::PHOTO ? ControlMode::C1 : ControlMode::REC; Serial.printf("MODE: %d\n", (int)controlMode); @@ -738,6 +933,7 @@ void beginManualSelection() { for (uint8_t i = 0; i < targetCameras; ++i) { Camera& camera = cameras[i]; camera.desired = false; + camera.armed = true; camera.transportConnected = false; camera.securityComplete = false; camera.securityOK = false; @@ -745,6 +941,9 @@ void beginManualSelection() { camera.ready = false; camera.pairingFailed = false; camera.recording = false; + camera.recordingKnown = false; + camera.recTimerApproximate = false; + camera.notifyReadable = false; camera.focusKnown = false; camera.focusLocked = false; camera.address = ""; @@ -765,6 +964,7 @@ bool connectDeviceToSlot(uint8_t slot, int deviceIndex) { camera.securityComplete = false; camera.securityOK = false; camera.serviceDiscoveryStarted = false; + camera.lastReconnectAttempt = millis(); status = String("C") + (slot + 1) + " CONNECTING..."; dirty = true; connectingSlot = slot; @@ -778,6 +978,7 @@ bool connectDeviceToSlot(uint8_t slot, int deviceIndex) { BLEAddress address(device.address.c_str()); if (!camera.client->connect(address)) { connectingSlot = -1; + camera.transportConnected = false; status = "LINK FAILED"; dirty = true; return false; @@ -788,11 +989,45 @@ bool connectDeviceToSlot(uint8_t slot, int deviceIndex) { bool connectSelected() { if (devices.empty()) return false; int slot = slotForAddress(devices[selected].address); + if (slot >= 0 && (cameras[slot].ready || cameras[slot].transportConnected)) { + status = String("ALREADY C") + (slot + 1); + Serial.printf("CONNECT: %s already occupies C%d\n", devices[selected].address.c_str(), slot + 1); + dirty = true; + return false; + } if (slot < 0) slot = firstFreeSlot(); if (slot < 0) { status = String(targetCameras) + " CAMERA SLOTS FULL"; dirty = true; return false; } return connectDeviceToSlot(slot, selected); } +void attemptNextReconnect() { + if ((uiState != UiState::DASHBOARD && uiState != UiState::TARGET_EDITOR) || connectingSlot >= 0) return; + for (uint8_t i = 0; i < targetCameras; ++i) { + Camera& camera = cameras[i]; + if (!camera.desired || camera.ready || camera.transportConnected) continue; + if (millis() - camera.lastReconnectAttempt < RECONNECT_COOLDOWN_MS) continue; + const int deviceIndex = deviceIndexForAddress(camera.address); + if (deviceIndex < 0) continue; + if (millis() - devices[deviceIndex].lastSeen > 3000) continue; + Serial.printf("C%d RECONNECT: camera advertisement returned\n", i + 1); + connectDeviceToSlot(i, deviceIndex); + return; + } +} + +void pollOneReadableStatus() { + if (uiState != UiState::DASHBOARD && uiState != UiState::TARGET_EDITOR) return; + for (uint8_t checked = 0; checked < targetCameras; ++checked) { + const uint8_t index = (statusReadCursor + checked) % targetCameras; + Camera& camera = cameras[index]; + if (!camera.ready || !camera.notifyReadable || + millis() - camera.lastStatusRead < STATUS_READ_INTERVAL_MS) continue; + statusReadCursor = (index + 1) % targetCameras; + readCameraStatus(index, "POLL"); + return; + } +} + bool pressed(Key& key) { bool now = digitalRead(key.pin); if (now != key.raw) { key.raw = now; key.changed = millis(); } @@ -877,13 +1112,17 @@ void handleSerialCommand() { if (!Serial.available()) return; String input = Serial.readStringUntil('\n'); input.trim(); input.toUpperCase(); - if (input == "REC") sendRecordClickAll("SERIAL"); - else if (input == "AF") sendMomentaryButtonAll("AF-ON", 0x15, 0x14); - else if (input == "PHOTO") startPhotoSequenceAll(); - else if (input == "C1") sendMomentaryButtonAll("C1", 0x21, 0x20); + if (input == "REC") sendRecordClickArmed(!anyArmedRecording(), "SERIAL"); + else if (input == "AF") sendMomentaryButtonArmed("AF-ON", 0x15, 0x14); + else if (input == "PHOTO") startPhotoSequenceArmed(); + else if (input == "C1") sendMomentaryButtonArmed("C1", 0x21, 0x20); else if (input == "MODE") nextControlMode(); else if (input == "STATUS") { - for (uint8_t i = 0; i < targetCameras; ++i) Serial.printf("C%d desired=%d ready=%d rec=%d addr=%s\n", i + 1, cameras[i].desired, cameras[i].ready, cameras[i].recording, cameras[i].address.c_str()); + for (uint8_t i = 0; i < targetCameras; ++i) { + Serial.printf("C%d desired=%d armed=%d ready=%d rec_known=%d rec=%d addr=%s\n", + i + 1, cameras[i].desired, cameras[i].armed, cameras[i].ready, + cameras[i].recordingKnown, cameras[i].recording, cameras[i].address.c_str()); + } } else Serial.println("SERIAL COMMAND: REC, AF, PHOTO, C1, MODE, STATUS"); } @@ -893,7 +1132,7 @@ void setup() { esp_log_level_set("*", ESP_LOG_DEBUG); Serial.println("\n=== ALPHABOOP MULTI-CAMERA BOOT (SELECT 1-4) ==="); const bool wokeFromLeftButton = esp_sleep_get_wakeup_cause() == ESP_SLEEP_WAKEUP_EXT0; - if (wokeFromLeftButton) Serial.println("POWER: left-button wake detected; waiting two seconds before display power"); + if (wokeFromLeftButton) Serial.println("POWER: left-button wake detected; running two-second confirmation animation"); // Keep the original namespace so existing camera pairings survive the rename. preferences.begin("sony-tally", false); // Migrate the first successful single-camera build to C1 automatically. @@ -906,8 +1145,8 @@ void setup() { } // GPIO0 wakes us through the RTC domain. Return it to regular GPIO before // the ordinary button debouncer takes over after waking. - rtc_gpio_deinit(static_cast(ENTER_BUTTON)); - pinMode(ENTER_BUTTON, INPUT_PULLUP); pinMode(NEXT_BUTTON, INPUT_PULLUP); + rtc_gpio_deinit(static_cast(SELECT_BUTTON)); + pinMode(SELECT_BUTTON, INPUT_PULLUP); pinMode(ACTION_BUTTON, INPUT_PULLUP); pinMode(PERIPHERAL_POWER, OUTPUT); digitalWrite(PERIPHERAL_POWER, HIGH); tft.init(); tft.setRotation(0); @@ -916,12 +1155,14 @@ void setup() { // a brightness gesture, so it gets no DIM toast. applyBrightness(false); if (wokeFromLeftButton) { - if (!playWakeHoldAnimation()) { + if (!playStartupAnimation(true)) { Serial.println("POWER: wake cancelled before two seconds"); enterDeepSleep(); } Serial.println("POWER: wake confirmed"); - } else drawBootAnimation(); + } else { + playStartupAnimation(false); + } redraw(); dirty = false; BLEDevice::init("Alphaboop"); BLEDevice::setEncryptionLevel(ESP_BLE_SEC_ENCRYPT); @@ -937,15 +1178,16 @@ void setup() { void loop() { handleSerialCommand(); - bool nextPressed = pressed(next); - bool enterPressed = pressed(enter); - bool nextShortReleased = false; + const bool selectPressed = pressed(selectKey); + const bool actionPressed = pressed(actionKey); + bool selectShortReleased = false; + bool actionShortReleased = false; // The wake button is held low when deep sleep ends. Wait for both buttons // to be released before accepting input, otherwise waking could also fire // REC. That would be a surprisingly literal power-on self-test. if (!inputsArmed) { - if (digitalRead(ENTER_BUTTON) == HIGH && digitalRead(NEXT_BUTTON) == HIGH) { + if (digitalRead(SELECT_BUTTON) == HIGH && digitalRead(ACTION_BUTTON) == HIGH) { if (!inputsReleasedAt) inputsReleasedAt = millis(); if (millis() - inputsReleasedAt >= 40) inputsArmed = true; } else { @@ -955,22 +1197,22 @@ void loop() { return; } - // RIGHT: tap = NEXT/MODE; 0.7 s = dim; keep holding for the three-second - // full-screen power countdown. A short action only fires on - // release, so a hold cannot also change the current mode. - if (nextPressed) { - nextHeld = true; - nextLongHandled = false; + // LEFT owns navigation and the display/power gesture. A short navigation + // event fires only on release, so a hold cannot also change a selection. + if (selectPressed) { + selectHeld = true; + selectLongHandled = false; powerOffCountdownActive = false; powerOffSecondsShown = 0; - nextPressedAt = millis(); + selectPressedAt = millis(); } - if (nextHeld && !next.stable && !nextLongHandled && millis() - nextPressedAt >= BRIGHTNESS_HOLD_MS) { + if (selectHeld && !selectKey.stable && !selectLongHandled && + millis() - selectPressedAt >= BRIGHTNESS_HOLD_MS) { cycleBrightness(); - nextLongHandled = true; + selectLongHandled = true; } - if (nextHeld && !next.stable && millis() - nextPressedAt >= POWER_COUNTDOWN_START_MS) { - const uint32_t heldFor = millis() - nextPressedAt; + if (selectHeld && !selectKey.stable && millis() - selectPressedAt >= POWER_COUNTDOWN_START_MS) { + const uint32_t heldFor = millis() - selectPressedAt; if (heldFor >= POWER_OFF_HOLD_MS) enterDeepSleep(); const uint8_t seconds = 2 - ((heldFor - POWER_COUNTDOWN_START_MS) / 1000); if (!powerOffCountdownActive) { @@ -983,13 +1225,26 @@ void loop() { drawPowerOffCountdown(seconds); } } - if (nextHeld && next.stable) { - if (!nextLongHandled) nextShortReleased = true; + if (selectHeld && selectKey.stable) { + if (!selectLongHandled) selectShortReleased = true; if (powerOffCountdownActive) restoreAfterPowerCountdown(); powerOffCountdownActive = false; powerOffSecondsShown = 0; - nextHeld = false; + selectHeld = false; + } + + // RIGHT owns confirmation and camera actions. REC uses the duration; other + // actions are deliberately dispatched on release. + if (actionPressed) { + actionHeld = true; + actionLongHandled = false; + actionPressedAt = millis(); + } + if (actionHeld && actionKey.stable) { + if (!actionLongHandled) actionShortReleased = true; + actionHeld = false; } + if (powerOffCountdownActive) { // Do not let REC-timer refreshes draw over the intentional power screen. // The BLE callback continues to receive the truth in the background. @@ -1005,11 +1260,11 @@ void loop() { // Camera count is deliberately chosen on every boot. It lets the same // Alphaboop work as a compact one-camera remote or a four-camera master. if (uiState == UiState::SELECT_CAMERA_COUNT) { - if (nextShortReleased) { + if (selectShortReleased) { targetCameras = targetCameras == MAX_CAMERAS ? 1 : targetCameras + 1; dirty = true; } - if (enterPressed) { + if (actionShortReleased) { beginManualSelection(); uiState = UiState::SELECT_CAMERAS; status = String("SELECT CAMERA 1/") + targetCameras; @@ -1028,24 +1283,43 @@ void loop() { } } if (!scanInProgress && connectingSlot < 0 && millis() - lastScan > 1500) startBackgroundScan(); + attemptNextReconnect(); + pollOneReadableStatus(); - if (!allCamerasLinked()) { - if (nextShortReleased && !devices.empty()) { selected = (selected + 1) % devices.size(); dirty = true; } - if (enterPressed) connectSelected(); - } else { - if (nextShortReleased) nextControlMode(); - if (enterPressed) { - if (controlMode == ControlMode::REC) { - enterHeld = true; enterLongHandled = false; enterPressedAt = millis(); - } else runSelectedAction(); + if (uiState == UiState::SELECT_CAMERAS) { + if (selectShortReleased && !devices.empty()) { + selected = (selected + 1) % devices.size(); + dirty = true; + } + if (actionShortReleased) connectSelected(); + } else if (uiState == UiState::TARGET_EDITOR) { + if (selectShortReleased) { + targetEditorCursor = (targetEditorCursor + 1) % (targetCameras + 1); + dirty = true; + } + if (actionShortReleased) { + if (targetEditorCursor < targetCameras) { + Camera& camera = cameras[targetEditorCursor]; + camera.armed = !camera.armed; + status = String("C") + (targetEditorCursor + 1) + (camera.armed ? " ARMED" : " MUTED"); + Serial.printf("TARGETS: C%d %s\n", targetEditorCursor + 1, camera.armed ? "ARMED" : "MUTED"); + } else { + uiState = UiState::DASHBOARD; + controlMode = ControlMode::REC; + Serial.printf("TARGETS: done; %u/%u armed\n", armedCameraCount(), targetCameras); + } + dirty = true; } - if (controlMode == ControlMode::REC && enterHeld && !enter.stable && !enterLongHandled && millis() - enterPressedAt >= STOP_HOLD_MS) { - if (anyRecording()) sendRecordClickAll("STOP"); - enterLongHandled = true; + } else if (uiState == UiState::DASHBOARD) { + if (selectShortReleased) nextControlMode(); + if (controlMode == ControlMode::REC && actionHeld && !actionKey.stable && + !actionLongHandled && millis() - actionPressedAt >= STOP_HOLD_MS) { + sendRecordClickArmed(false, "HOLD STOP"); + actionLongHandled = true; } - if (enterHeld && enter.stable) { - if (controlMode == ControlMode::REC && !enterLongHandled && !anyRecording()) sendRecordClickAll("START"); - enterHeld = false; + if (actionShortReleased) { + if (controlMode == ControlMode::REC) sendRecordClickArmed(true, "TAP START"); + else runSelectedAction(); } } processCameraTimers(); From 66908bb4b80c68cb11adc8ca87dff1ecc653f198 Mon Sep 17 00:00:00 2001 From: iffun Date: Wed, 29 Jul 2026 23:33:10 +0300 Subject: [PATCH 2/5] feat: streamline startup star flight --- README.md | 2 +- sony-tally/docs/control-design-v2.md | 12 ++-- sony-tally/src/main.cpp | 86 +++++++++++++--------------- 3 files changed, 47 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index e502b2f..099da8f 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Alphaboop runs on a [LILYGO T-Display-S3](https://www.lilygo.cc/products/t-displ - **left**: short press selects the next item/mode; hold for 0.5 s cycles display brightness `100% → 50% → MIN`; at 1.5 s the full-screen power countdown begins; - **right**: confirm or run the selected action; in REC, tap starts the armed cameras and a hold stops the armed cameras. - Shows a large single-camera dashboard or dedicated layouts for two, three, and four cameras. -- Hold the **left** button for 3.5 seconds to sleep. It dims at 0.5 seconds, then uses a full-screen green `POWER OFF 2 → 1`; releasing it cancels. To wake, hold the **left** button for the unified two-second 8-bit camera animation. Cold boot gets the same production. Reset is still reset. It has paperwork. +- Hold the **left** button for 3.5 seconds to sleep. It dims at 0.5 seconds, then uses a full-screen green `POWER OFF 2 → 1`; releasing it cancels. To wake, hold the **left** button through the one-second star-flight animation. Cold boot gets the same production. Reset is still reset. It has paperwork. The camera remains the authority. It has earned this privilege. diff --git a/sony-tally/docs/control-design-v2.md b/sony-tally/docs/control-design-v2.md index 6a3b797..37c6f18 100644 --- a/sony-tally/docs/control-design-v2.md +++ b/sony-tally/docs/control-design-v2.md @@ -15,7 +15,7 @@ The labels follow the buttons' positions on the front of the T-Display-S3. | Dashboard | Next control mode | Run selected action | | REC mode | Next control mode | Tap to start armed cameras; hold to stop armed cameras | | Any powered-on screen | Hold: DIM, then power-off countdown | Context action | -| Deep sleep | Hold for two seconds to wake | No action | +| Deep sleep | Hold through the one-second animation to wake | No action | The power gesture moves with the navigation role to the left button. This keeps the right button unambiguously responsible for actions, including the REC @@ -85,13 +85,13 @@ The UI must never imply that a locally counted timer is camera timecode. ## Unified startup animation -Cold boot and deep-sleep wake use the same two-second, code-rendered 8-bit +Cold boot and deep-sleep wake use the same one-second, code-rendered 8-bit sequence: -1. a limited-palette star field wakes up; -2. a pixel camera assembles in the centre; -3. the lens receives one restrained orange “boop” pulse; -4. the pixels resolve into `ALPHABOOP` and `by @iffun`. +1. stars accelerate from the centre into short perspective streaks; +2. the flight arrives at `ALPHABOOP`; +3. the title moves into the exact header position of the next screen; +4. the remaining UI draws without clearing that final frame. Deep-sleep wake requires the left button to remain held for the whole animation. Cold boot plays the same sequence without the hold requirement. diff --git a/sony-tally/src/main.cpp b/sony-tally/src/main.cpp index b6aa69f..f39e86d 100644 --- a/sony-tally/src/main.cpp +++ b/sony-tally/src/main.cpp @@ -17,7 +17,7 @@ constexpr uint32_t STOP_HOLD_MS = 700; constexpr uint32_t BRIGHTNESS_HOLD_MS = 500; constexpr uint32_t POWER_COUNTDOWN_START_MS = 1500; constexpr uint32_t POWER_OFF_HOLD_MS = 3500; -constexpr uint32_t WAKE_CONFIRM_HOLD_MS = 2000; +constexpr uint32_t STARTUP_ANIMATION_MS = 1050; constexpr uint32_t RECONNECT_COOLDOWN_MS = 5000; constexpr uint32_t STATUS_READ_INTERVAL_MS = 5000; // The T-Display-S3's backlight driver is not PWM: it changes between its @@ -462,8 +462,8 @@ void drawBrightnessToast() { tft.setTextDatum(TL_DATUM); } -void redraw() { - tft.fillScreen(TFT_BLACK); +void redraw(bool clearScreen = true) { + if (clearScreen) tft.fillScreen(TFT_BLACK); tft.setTextDatum(TL_DATUM); renderedView = currentScreenView(); if (renderedView == ScreenView::CAMERA_COUNT) drawCountSelectionScreen(); @@ -534,19 +534,21 @@ void restoreAfterPowerCountdown() { } bool playStartupAnimation(bool requireLeftHold) { - // Cold boot and wake get the same small 8-bit production. Rendering into a - // sprite keeps the TFT from showing the black clear between frames. + // A short star-flight ends on the exact title position used by the camera + // count screen. The next UI can therefore appear without a black transition. TFT_eSprite frame(&tft); frame.setColorDepth(8); if (!frame.createSprite(170, 320)) { Serial.println("DISPLAY: startup sprite allocation failed"); + tft.fillScreen(TFT_BLACK); return !requireLeftHold || digitalRead(SELECT_BUTTON) == LOW; } - constexpr uint8_t starX[] = {8, 27, 49, 73, 97, 121, 148, 161, 18, 62, 112, 139}; - constexpr uint8_t starY[] = {18, 61, 107, 34, 82, 15, 119, 52, 151, 137, 174, 202}; - constexpr uint8_t frames = 40; - constexpr uint16_t frameMs = WAKE_CONFIRM_HOLD_MS / frames; + constexpr int8_t starDx[] = {-12, -9, -6, -3, 3, 6, 9, 12, -11, -7, -4, 4, 7, 11, -10, -5, 5, 10}; + constexpr int8_t starDy[] = {-7, -12, -5, -11, -10, -5, -12, -7, 6, 11, 5, 5, 11, 6, 1, -2, 2, -1}; + constexpr uint8_t starPhase[] = {0, 37, 71, 18, 52, 89, 28, 63, 9, 46, 82, 24, 58, 95, 33, 76, 14, 67}; + constexpr uint8_t frames = 25; + constexpr uint16_t frameMs = STARTUP_ANIMATION_MS / frames; for (uint8_t f = 0; f < frames; ++f) { if (requireLeftHold && digitalRead(SELECT_BUTTON) != LOW) { @@ -555,44 +557,36 @@ bool playStartupAnimation(bool requireLeftHold) { } frame.fillSprite(TFT_BLACK); - // A sparse star field, because even boot screens need somewhere to work. - for (uint8_t i = 0; i < sizeof(starX); ++i) { - const int y = (starY[i] + f * (1 + i % 3) * 2) % 250; - const uint16_t color = (i + f) % 5 == 0 ? TFT_ORANGE : - (i + f) % 2 == 0 ? TFT_CYAN : TFT_DARKGREY; - frame.fillRect(starX[i], y, (i % 4 == 0) ? 3 : 2, (i % 4 == 0) ? 3 : 2, color); + // Points accelerate away from the centre and become short streaks. During + // the title move they thin out until the final frame is already the UI. + if (f < frames - 1) { + for (uint8_t i = 0; i < sizeof(starDx); ++i) { + if (f >= 16 && (i % 9) < f - 15) continue; + const int travel = (f * 11 + starPhase[i]) % 120; + const int radius = 2 + (travel * travel) / 85; + const int previousRadius = max(0, radius - 7 - travel / 24); + const int x = 85 + (starDx[i] * radius) / 12; + const int y = 150 + (starDy[i] * radius) / 12; + const int oldX = 85 + (starDx[i] * previousRadius) / 12; + const int oldY = 150 + (starDy[i] * previousRadius) / 12; + if (x < 0 || x >= 170 || y < 0 || y >= 320) continue; + const uint16_t color = i % 7 == 0 ? TFT_ORANGE : i % 3 == 0 ? TFT_CYAN : TFT_WHITE; + frame.drawLine(oldX, oldY, x, y, color); + if (travel > 75) frame.fillRect(x, y, 2, 2, color); + } } - if (f < 28) { - const int assembled = min((int)f, 14); - const int left = 27 + (14 - assembled) * 3; - const int right = 143 - (14 - assembled) * 3; - if (left < right) { - frame.drawRoundRect(left, 112, right - left, 73, 4, TFT_LIGHTGREY); - frame.fillRect(left + 10, 101, min(43, max(0, assembled * 4)), 12, TFT_LIGHTGREY); - frame.fillRect(right - 24, 103, min(16, max(0, assembled * 2)), 8, TFT_ORANGE); - } - if (f >= 7) { - const int radius = min(29, 5 + (f - 7) * 2); - frame.drawCircle(85, 149, radius, TFT_CYAN); - if (radius > 12) frame.drawCircle(85, 149, radius - 9, TFT_LIGHTGREY); - } - if (f >= 18) { - const int pulse = 7 + abs(22 - f) * 3; - frame.drawCircle(85, 149, min(pulse, 36), TFT_ORANGE); - frame.fillCircle(85, 149, 4, TFT_ORANGE); - } - frame.setTextDatum(MC_DATUM); - frame.setTextColor(TFT_DARKGREY, TFT_BLACK); - frame.drawString("INITIALISING REALITY", 85, 234, 2); - } else { - const uint8_t reveal = min((int)9, 1 + (f - 28)); + if (f >= 10 && f < 16) { frame.setTextDatum(MC_DATUM); + frame.setTextColor(f < 12 ? TFT_DARKGREY : TFT_WHITE, TFT_BLACK); + frame.drawString("ALPHABOOP", 85, 150, 4); + } else if (f >= 16) { + const int move = f - 16; + const int x = 18 - (move * 10) / 8; + const int y = 140 - (move * 128) / 8; + frame.setTextDatum(TL_DATUM); frame.setTextColor(TFT_WHITE, TFT_BLACK); - frame.drawString(String("ALPHABOOP").substring(0, reveal), 85, 138, 4); - frame.setTextColor(TFT_ORANGE, TFT_BLACK); - if (f >= 34) frame.drawString("by @iffun", 85, 172, 2); - frame.drawFastHLine(35, 199, min(100, (f - 28) * 10), TFT_GREEN); + frame.drawString("ALPHABOOP", x, y, 4); } frame.pushSprite(0, 0); @@ -1132,7 +1126,7 @@ void setup() { esp_log_level_set("*", ESP_LOG_DEBUG); Serial.println("\n=== ALPHABOOP MULTI-CAMERA BOOT (SELECT 1-4) ==="); const bool wokeFromLeftButton = esp_sleep_get_wakeup_cause() == ESP_SLEEP_WAKEUP_EXT0; - if (wokeFromLeftButton) Serial.println("POWER: left-button wake detected; running two-second confirmation animation"); + if (wokeFromLeftButton) Serial.println("POWER: left-button wake detected; running one-second confirmation animation"); // Keep the original namespace so existing camera pairings survive the rename. preferences.begin("sony-tally", false); // Migrate the first successful single-camera build to C1 automatically. @@ -1156,14 +1150,14 @@ void setup() { applyBrightness(false); if (wokeFromLeftButton) { if (!playStartupAnimation(true)) { - Serial.println("POWER: wake cancelled before two seconds"); + Serial.println("POWER: wake cancelled before animation completed"); enterDeepSleep(); } Serial.println("POWER: wake confirmed"); } else { playStartupAnimation(false); } - redraw(); dirty = false; + redraw(false); dirty = false; BLEDevice::init("Alphaboop"); BLEDevice::setEncryptionLevel(ESP_BLE_SEC_ENCRYPT); BLEDevice::setSecurityCallbacks(new Security()); From f94956947c7bb47ae99130587f9d0c8c34e72dc3 Mon Sep 17 00:00:00 2001 From: iffun Date: Wed, 29 Jul 2026 23:53:43 +0300 Subject: [PATCH 3/5] feat: simplify controls and refine startup arc --- README.md | 21 +- sony-tally/docs/control-design-v2.md | 29 +- sony-tally/docs/revision-2026-07-29.md | 56 +++ sony-tally/src/main.cpp | 462 ++++++++++++++---------- sony-tally/tools/send_serial_command.py | 5 +- 5 files changed, 351 insertions(+), 222 deletions(-) create mode 100644 sony-tally/docs/revision-2026-07-29.md diff --git a/README.md b/README.md index 099da8f..47cedb9 100644 --- a/README.md +++ b/README.md @@ -22,20 +22,23 @@ Alphaboop runs on a [LILYGO T-Display-S3](https://www.lilygo.cc/products/t-displ - Lets you arm any subset of a two-to-four-camera group. Connected cameras stay visible even when they are not receiving commands. - Provides a two-button interface: - - **left**: short press selects the next item/mode; hold for 0.5 s cycles display brightness `100% → 50% → MIN`; at 1.5 s the full-screen power countdown begins; - - **right**: confirm or run the selected action; in REC, tap starts the armed cameras and a hold stops the armed cameras. + - **left**: tap cycles `ALL → C1 → C2 → C3 → C4`; hold toggles the selected target; + - **right**: tap starts armed cameras; hold stops armed cameras; + - **both together**: hold for 0.5 s to cycle display brightness `100% → 50% → MIN`; keep holding for the power-off countdown. - Shows a large single-camera dashboard or dedicated layouts for two, three, and four cameras. -- Hold the **left** button for 3.5 seconds to sleep. It dims at 0.5 seconds, then uses a full-screen green `POWER OFF 2 → 1`; releasing it cancels. To wake, hold the **left** button through the one-second star-flight animation. Cold boot gets the same production. Reset is still reset. It has paperwork. +- Hold **both** buttons for 3.5 seconds to sleep. Brightness changes at 0.5 seconds, then a full-screen green `POWER OFF 2 → 1` appears; releasing either button cancels. To wake, hold the **left** button through the one-second star-flight animation. Cold boot gets the same production. Reset is still reset. It has paperwork. The camera remains the authority. It has earned this privilege. ## Choosing command targets -For two to four cameras, cycle to `TARGETS`, then press the right button: +Targets are edited directly on the dashboard: -1. Left moves through C1…Cn and `DONE`. -2. Right toggles the highlighted camera `ON`/`OFF`. -3. Select `DONE` and press right to return to REC. +1. Tap left to move through `ALL`, C1…Cn. +2. Hold left to toggle that camera. +3. On `ALL`, holding left arms every camera if any are off, or mutes all if all + are already armed. +4. Tap or hold right for REC start/stop without leaving the dashboard. An armed camera keeps that setting through a temporary disconnect. Zero targets is valid and sends nothing. Sometimes every camera deserves a quiet @@ -95,7 +98,8 @@ The helper defaults to `/dev/cu.usbmodem2101`. Computers contain variety. Adjust ## Remote controls -Alphaboop uses Sony's BLE Remote Control service: +Alphaboop's front-panel UI deliberately exposes REC only. The underlying Sony +BLE Remote Control service also contains these commands for diagnostics: | Control | Action | | --- | --- | @@ -133,6 +137,7 @@ sony-tally/ include/TFTSetup.h T-Display-S3 display pin configuration docs/ble-capabilities.md verified BLE findings and command map docs/control-design-v2.md two-button and reconnect behaviour contract + docs/revision-2026-07-29.md regression review and remaining hardware tests docs/wifi-feasibility.md Wi-Fi research and boundaries tools/ serial diagnostics work/SonyBleProbe.swift macOS BLE probe; read-only by default diff --git a/sony-tally/docs/control-design-v2.md b/sony-tally/docs/control-design-v2.md index 37c6f18..f31e483 100644 --- a/sony-tally/docs/control-design-v2.md +++ b/sony-tally/docs/control-design-v2.md @@ -12,14 +12,12 @@ The labels follow the buttons' positions on the front of the T-Display-S3. | --- | --- | --- | | Camera count | Next option | Select | | Camera discovery | Next device | Connect/select | -| Dashboard | Next control mode | Run selected action | -| REC mode | Next control mode | Tap to start armed cameras; hold to stop armed cameras | -| Any powered-on screen | Hold: DIM, then power-off countdown | Context action | +| Dashboard | Tap: next target; hold: toggle target | Tap: REC start; hold: REC stop | +| Any powered-on screen | Hold both: DIM, then power-off countdown | Hold both: same chord | | Deep sleep | Hold through the one-second animation to wake | No action | -The power gesture moves with the navigation role to the left button. This keeps -the right button unambiguously responsible for actions, including the REC -tap/hold distinction. +Brightness and power require both buttons, so neither production control shares +a long-press threshold with power. ## Selecting command targets @@ -31,12 +29,11 @@ All configured cameras remain visible. `armed` is independent of `connected`: again after reconnecting; - unarmed + disconnected: remains visible and quiet. -For two to four camera slots, `TARGETS` appears in the ordinary mode cycle: +Target selection stays on the dashboard: -1. Left cycles to `TARGETS`. -2. Right opens the target editor. -3. Left cycles through C1…Cn and `DONE`. -4. Right toggles the highlighted camera, or confirms `DONE`. +1. Tap left to cycle `ALL → C1 → C2 → C3 → C4 → ALL`. +2. Hold left to toggle the highlighted camera. +3. Holding on `ALL` arms all when any are off, or mutes all when all are on. Zero armed cameras is allowed. An action then reports `NO TARGETS` and sends nothing. The default after camera selection is all cameras armed. @@ -105,14 +102,14 @@ Run this before merging the branch into the stable release: 1. Cold boot and deep-sleep wake show the same animation; an early release during wake returns to sleep. 2. On the count and discovery screens, left moves and right confirms. -3. Connect two or more cameras and use `TARGETS` to arm only C1, then C1+C3, - then none, then all. -4. Verify REC start/stop and C1 are sent only to armed slots. +3. Connect two or more cameras. On the dashboard, tap left to select and hold + left to create C1-only, C1+C3, none, and all target sets. +4. Verify right tap/hold starts and stops only armed slots. 5. Turn off one camera while another is recording. The dashboard and remaining controls must stay alive; the lost slot must say `DISCONNECTED`. 6. Turn that camera back on. It must reconnect into its original slot without changing its armed state. 7. Connect Alphaboop while a camera is already recording. Record whether FF02 provides a snapshot, is readable, or remains unknown; keep the serial log. -8. Hold left through DIM and cancel the power countdown, then complete it. - Right must never change brightness. +8. Hold both through DIM and cancel the power countdown by releasing either + button, then complete it. Neither individual button may change brightness. diff --git a/sony-tally/docs/revision-2026-07-29.md b/sony-tally/docs/revision-2026-07-29.md new file mode 100644 index 0000000..266db4c --- /dev/null +++ b/sony-tally/docs/revision-2026-07-29.md @@ -0,0 +1,56 @@ +# Firmware revision — 2026-07-29 + +This is the regression review performed after the first four-camera field test. + +## Corrected + +- Removed the front-panel `AF-ON`, `PHOTO`, `C1`, and mode carousel. The + production UI now spends both buttons on target selection and REC. +- Replaced the startup title's mixed centre/top-left coordinates with one + continuously scaled bitmap and a cubic path that ends at the real header + coordinates. +- Moved DIM and power-off to a two-button chord so target toggle and REC stop + do not share a long-press gesture with power. +- Restored footer labels after the DIM toast disappears. The toast previously + overlapped the top pixels of the right-hand label. +- Added a 15-second connection timeout. A security or GATT attempt can no + longer occupy the only connection slot forever. +- Stop the asynchronous scan before copying the selected advertisement and + reserve the device list capacity. This reduces the chance of a scan callback + invalidating data while a connection begins. +- Kept REC start and stop state-aware: start skips known-recording cameras; + stop skips known-stopped cameras. Mixed groups therefore converge instead of + blindly toggling in opposite directions. + +## Rechecked + +- Losing one camera does not leave the dashboard. +- Disconnected cameras remain grey, retain their armed state, and reconnect by + BLE address. +- Commands are filtered by both `armed` and `ready`. +- FF02 `02 D5` remains the only authority that turns the UI red. +- Timer refreshes redraw only timer/row regions, not the whole display. +- Wake requires the left button through the animation, then waits for release + before accepting REC input. +- Releasing either button cancels the power chord countdown. + +## Protocol limits, not firmware defects + +- FF02 is normally notification-only. A camera may not reveal whether it was + already recording when Alphaboop connects. +- The displayed duration is a local timer, not Sony timecode. +- Four BLE REC commands are sequential and close together, but they are not + frame-accurate synchronization. + +## Hardware regression still required + +1. Observe the complete title path on the real 170 × 320 panel. +2. Test tap/hold/chord separation with both physical buttons. +3. Build target sets `C1`, `C1+C3`, none, and all. +4. Start a mixed group where one camera is already recording. +5. Power-cycle one camera and confirm automatic return to its original slot. +6. Leave one camera unavailable for more than 15 seconds and verify the other + cameras remain controllable. + +The code has passed compilation and static state-flow review. Cameras retain +the final vote. They usually insist. diff --git a/sony-tally/src/main.cpp b/sony-tally/src/main.cpp index f39e86d..529e7df 100644 --- a/sony-tally/src/main.cpp +++ b/sony-tally/src/main.cpp @@ -14,12 +14,14 @@ constexpr int PERIPHERAL_POWER = 15; // required to power LCD/peripherals from b constexpr int SINGLE_LEFT = 6; constexpr uint8_t MAX_CAMERAS = 4; constexpr uint32_t STOP_HOLD_MS = 700; +constexpr uint32_t TARGET_TOGGLE_HOLD_MS = 650; constexpr uint32_t BRIGHTNESS_HOLD_MS = 500; constexpr uint32_t POWER_COUNTDOWN_START_MS = 1500; constexpr uint32_t POWER_OFF_HOLD_MS = 3500; constexpr uint32_t STARTUP_ANIMATION_MS = 1050; constexpr uint32_t RECONNECT_COOLDOWN_MS = 5000; constexpr uint32_t STATUS_READ_INTERVAL_MS = 5000; +constexpr uint32_t CONNECT_TIMEOUT_MS = 15000; // The T-Display-S3's backlight driver is not PWM: it changes between its // sixteen hardware levels by counting rising edges on GPIO38. This mirrors // LilyGO's factory firmware. An ordinary PWM signal occasionally lands on a @@ -30,10 +32,9 @@ constexpr const char* BRIGHTNESS_LABELS[] = {"100%", "50%", "MIN"}; const BLEUUID SONY_SERVICE("8000FF00-FF00-FFFF-FFFF-FFFFFFFFFFFF"); const BLEUUID FF01((uint16_t)0xFF01), FF02((uint16_t)0xFF02); -enum class ControlMode { REC, TARGETS, AF_ON, PHOTO, C1 }; enum class PhotoStep { IDLE, FULL_DOWN, HALF_UP, FULL_UP }; -enum class UiState { SELECT_CAMERA_COUNT, SELECT_CAMERAS, TARGET_EDITOR, DASHBOARD }; -enum class ScreenView { NONE, CAMERA_COUNT, CAMERA_LIST, TARGET_EDITOR, SINGLE_DASHBOARD, MULTI_DASHBOARD }; +enum class UiState { SELECT_CAMERA_COUNT, SELECT_CAMERAS, DASHBOARD }; +enum class ScreenView { NONE, CAMERA_COUNT, CAMERA_LIST, SINGLE_DASHBOARD, MULTI_DASHBOARD }; struct Device { String name; @@ -92,22 +93,26 @@ bool dirty = true; ScreenView renderedView = ScreenView::NONE; int selected = 0; int connectingSlot = -1; // Pair one new camera at a time. Sony security is a one-conversation professional. +uint32_t connectingStartedAt = 0; uint32_t lastScan = 0, lastTimer = 0; String status = "SCANNING..."; -ControlMode controlMode = ControlMode::REC; UiState uiState = UiState::SELECT_CAMERA_COUNT; uint8_t targetCameras = 1; +int8_t dashboardTarget = -1; // -1 means ALL, otherwise C1..C4. bool actionHeld = false, actionLongHandled = false; uint32_t actionPressedAt = 0; bool selectHeld = false, selectLongHandled = false; uint32_t selectPressedAt = 0; +bool powerChordActive = false; +bool powerChordBrightnessHandled = false; +bool suppressButtonsUntilReleased = false; +uint32_t powerChordStartedAt = 0; bool powerOffCountdownActive = false; uint8_t powerOffSecondsShown = 0; bool inputsArmed = false; uint32_t inputsReleasedAt = 0; uint8_t brightnessIndex = 0; uint32_t brightnessToastUntil = 0; -uint8_t targetEditorCursor = 0; uint8_t statusReadCursor = 0; struct Key { int pin; bool raw, stable; uint32_t changed; }; @@ -155,25 +160,42 @@ uint8_t armedCameraCount() { return result; } -const char* controlModeName() { - switch (controlMode) { - case ControlMode::REC: return "REC"; - case ControlMode::TARGETS: return "TARGETS"; - case ControlMode::AF_ON: return "AF-ON"; - case ControlMode::PHOTO: return "PHOTO"; - case ControlMode::C1: return "C1"; +const char* actionButtonLabel() { + if (!armedCameraCount()) return "NO TARGETS"; + return anyArmedRecording() ? "HOLD STOP" : targetCameras == 1 ? "REC" : "REC ARMED"; +} + +String dashboardTargetName() { + return dashboardTarget < 0 ? "ALL" : String("C") + (dashboardTarget + 1); +} + +void cycleDashboardTarget() { + if (targetCameras <= 1) { + dashboardTarget = 0; + } else if (dashboardTarget < 0) { + dashboardTarget = 0; + } else if (dashboardTarget + 1 >= targetCameras) { + dashboardTarget = -1; + } else { + ++dashboardTarget; } - return "?"; + Serial.printf("TARGET: %s\n", dashboardTargetName().c_str()); + dirty = true; } -const char* actionButtonLabel() { - if (controlMode == ControlMode::REC) { - if (!armedCameraCount()) return "NO TARGETS"; - return anyArmedRecording() ? "HOLD STOP" : targetCameras == 1 ? "REC" : "REC ARMED"; +void toggleDashboardTarget() { + if (targetCameras <= 1) dashboardTarget = 0; + if (dashboardTarget < 0) { + const bool armAll = armedCameraCount() != targetCameras; + for (uint8_t i = 0; i < targetCameras; ++i) cameras[i].armed = armAll; + status = armAll ? "ALL ARMED" : "ALL MUTED"; + } else { + Camera& camera = cameras[dashboardTarget]; + camera.armed = !camera.armed; + status = String("C") + (dashboardTarget + 1) + (camera.armed ? " ARMED" : " MUTED"); } - if (controlMode == ControlMode::TARGETS) return "EDIT"; - if (controlMode == ControlMode::C1) return "C1"; - return controlModeName(); + Serial.printf("TARGET: %s; %u/%u armed\n", status.c_str(), armedCameraCount(), targetCameras); + dirty = true; } String cameraName(const Camera& camera) { @@ -186,6 +208,17 @@ String compactName(const String& name, size_t maxLength = 20) { return name.substring(0, maxLength - 1) + "~"; } +void drawFooterLabels(const char* left, const char* right) { + tft.fillRect(0, 300, 170, 20, TFT_BLACK); + tft.setTextColor(TFT_ORANGE, TFT_BLACK); + tft.setTextDatum(BL_DATUM); + tft.drawString(left, 5, 313, 2); + tft.setTextColor(TFT_CYAN, TFT_BLACK); + tft.setTextDatum(BR_DATUM); + tft.drawString(right, 165, 313, 2); + tft.setTextDatum(TL_DATUM); +} + void drawCountOptions() { tft.setTextColor(TFT_WHITE, TFT_BLACK); for (uint8_t count = 1; count <= MAX_CAMERAS; ++count) { @@ -206,13 +239,7 @@ void drawCountSelectionScreen() { drawCountOptions(); tft.setTextColor(TFT_DARKGREY, TFT_BLACK); tft.drawString("RESTART TO CHANGE", 8, 256, 2); - tft.setTextColor(TFT_ORANGE, TFT_BLACK); - tft.setTextDatum(BL_DATUM); - tft.drawString("NEXT", 5, 313, 2); - tft.setTextColor(TFT_CYAN, TFT_BLACK); - tft.setTextDatum(BR_DATUM); - tft.drawString("SELECT", 165, 313, 2); - tft.setTextDatum(TL_DATUM); + drawFooterLabels("NEXT", "SELECT"); } void drawSelectionBody() { @@ -246,49 +273,7 @@ void drawSelectionScreen() { tft.setTextColor(TFT_CYAN, TFT_BLACK); tft.drawString(String("ALPHABOOP ") + configuredCameraCount() + "/" + targetCameras, 4, 5, 2); drawSelectionBody(); - tft.setTextColor(TFT_ORANGE, TFT_BLACK); - tft.setTextDatum(BL_DATUM); - tft.drawString("NEXT", 5, 313, 2); - tft.setTextColor(TFT_CYAN, TFT_BLACK); - tft.setTextDatum(BR_DATUM); - tft.drawString("CONNECT", 165, 313, 2); - tft.setTextDatum(TL_DATUM); -} - -void drawTargetEditor() { - tft.setTextColor(TFT_GREEN, TFT_BLACK); - tft.drawString("COMMAND TARGETS", 7, 9, 2); - tft.setTextColor(TFT_DARKGREY, TFT_BLACK); - tft.drawString("CONNECTED OR NOT", 7, 29, 2); - - for (uint8_t i = 0; i < targetCameras; ++i) { - const Camera& camera = cameras[i]; - const int y = 59 + i * 42; - const bool cursor = targetEditorCursor == i; - const uint16_t stateColor = camera.ready ? TFT_WHITE : TFT_DARKGREY; - tft.drawRoundRect(4, y, 162, 35, 5, cursor ? TFT_GREEN : TFT_DARKGREY); - tft.setTextColor(cursor ? TFT_GREEN : TFT_LIGHTGREY, TFT_BLACK); - tft.drawString(cursor ? ">" : " ", 9, y + 9, 2); - tft.setTextColor(camera.armed ? TFT_ORANGE : TFT_DARKGREY, TFT_BLACK); - tft.drawString(camera.armed ? "ON" : "OFF", 24, y + 9, 2); - tft.setTextColor(stateColor, TFT_BLACK); - tft.drawString(String("C") + (i + 1), 54, y + 9, 2); - tft.setTextDatum(TR_DATUM); - tft.drawString(compactName(camera.name, 12), 158, y + 9, 2); - tft.setTextDatum(TL_DATUM); - } - - const int doneY = 59 + targetCameras * 42 + 5; - const bool doneSelected = targetEditorCursor == targetCameras; - tft.setTextColor(doneSelected ? TFT_GREEN : TFT_LIGHTGREY, TFT_BLACK); - tft.drawString(doneSelected ? "> DONE" : " DONE", 9, doneY, 4); - tft.setTextColor(TFT_ORANGE, TFT_BLACK); - tft.setTextDatum(BL_DATUM); - tft.drawString("NEXT", 5, 313, 2); - tft.setTextColor(TFT_CYAN, TFT_BLACK); - tft.setTextDatum(BR_DATUM); - tft.drawString(doneSelected ? "DONE" : "TOGGLE", 165, 313, 2); - tft.setTextDatum(TL_DATUM); + drawFooterLabels("NEXT", "CONNECT"); } void multiRowLayout(uint8_t index, int& y, int& height, uint8_t& timerFont) { @@ -322,9 +307,11 @@ void drawCameraRow(uint8_t index) { uint16_t color = camera.recording ? TFT_RED : camera.ready ? TFT_WHITE : TFT_DARKGREY; uint16_t border = camera.recording ? TFT_RED : camera.armed ? TFT_ORANGE : TFT_DARKGREY; tft.drawRoundRect(3, y, 164, height, 6, border); - tft.fillRect(9, y + 8, 5, 5, camera.armed ? TFT_ORANGE : TFT_DARKGREY); + tft.setTextColor(dashboardTarget == (int8_t)index ? TFT_GREEN : TFT_DARKGREY, TFT_BLACK); + tft.drawString(dashboardTarget == (int8_t)index ? ">" : " ", 7, y + 7, 2); + tft.fillRect(18, y + 8, 5, 5, camera.armed ? TFT_ORANGE : TFT_DARKGREY); tft.setTextColor(color, TFT_BLACK); - tft.drawString(String("C") + (index + 1), 18, y + 7, 2); + tft.drawString(String("C") + (index + 1), 27, y + 7, 2); tft.setTextColor(color, TFT_BLACK); tft.setTextDatum(TR_DATUM); tft.drawString(compactName(cameraName(camera), targetCameras == 2 ? 19 : 16), 159, y + 7, 2); @@ -388,18 +375,11 @@ void drawSingleRecordState(const Camera& camera) { void drawSingleControls() { tft.fillRect(0, 230, 170, 33, TFT_BLACK); tft.setTextColor(TFT_CYAN, TFT_BLACK); - tft.drawString(String("MODE: ") + controlModeName(), SINGLE_LEFT, 240, 2); + tft.drawString(String("TARGET C1: ") + (cameras[0].armed ? "ON" : "OFF"), SINGLE_LEFT, 240, 2); } void drawDashboardFooter() { - tft.fillRect(0, 300, 170, 20, TFT_BLACK); - tft.setTextColor(TFT_ORANGE, TFT_BLACK); - tft.setTextDatum(BL_DATUM); - tft.drawString("MODE", 5, 313, 2); - tft.setTextColor(TFT_CYAN, TFT_BLACK); - tft.setTextDatum(BR_DATUM); - tft.drawString(actionButtonLabel(), 165, 313, 2); - tft.setTextDatum(TL_DATUM); + drawFooterLabels("TARGET", actionButtonLabel()); } void drawSingleDashboard() { @@ -423,7 +403,8 @@ void drawMultiDashboard() { tft.setTextDatum(TL_DATUM); for (uint8_t i = 0; i < targetCameras; ++i) drawCameraRow(i); tft.setTextColor(TFT_CYAN, TFT_BLACK); - tft.drawString(String("MODE: ") + controlModeName() + " " + armedCameraCount() + " ARMED", 8, 265, 2); + tft.drawString(String("TARGET: ") + dashboardTargetName() + " " + armedCameraCount() + "/" + + targetCameras + " ON", 8, 265, 2); drawDashboardFooter(); } @@ -449,7 +430,6 @@ void refreshRecordingDisplay() { ScreenView currentScreenView() { if (uiState == UiState::SELECT_CAMERA_COUNT) return ScreenView::CAMERA_COUNT; if (uiState == UiState::SELECT_CAMERAS) return ScreenView::CAMERA_LIST; - if (uiState == UiState::TARGET_EDITOR) return ScreenView::TARGET_EDITOR; return targetCameras == 1 ? ScreenView::SINGLE_DASHBOARD : ScreenView::MULTI_DASHBOARD; } @@ -468,7 +448,6 @@ void redraw(bool clearScreen = true) { renderedView = currentScreenView(); if (renderedView == ScreenView::CAMERA_COUNT) drawCountSelectionScreen(); else if (renderedView == ScreenView::CAMERA_LIST) drawSelectionScreen(); - else if (renderedView == ScreenView::TARGET_EDITOR) drawTargetEditor(); else if (renderedView == ScreenView::SINGLE_DASHBOARD) drawSingleDashboard(); else if (renderedView == ScreenView::MULTI_DASHBOARD) drawMultiDashboard(); drawBrightnessToast(); @@ -489,9 +468,6 @@ void refreshCurrentScreen() { } else if (view == ScreenView::CAMERA_LIST) { tft.fillRect(0, 20, 170, 250, TFT_BLACK); drawSelectionBody(); - } else if (view == ScreenView::TARGET_EDITOR) { - tft.fillRect(0, 50, 170, 250, TFT_BLACK); - drawTargetEditor(); } else if (view == ScreenView::SINGLE_DASHBOARD) { const Camera& camera = cameras[0]; drawSingleRecordState(camera); @@ -504,7 +480,8 @@ void refreshCurrentScreen() { for (uint8_t i = 0; i < targetCameras; ++i) drawCameraRow(i); tft.fillRect(0, 260, 170, 24, TFT_BLACK); tft.setTextColor(TFT_CYAN, TFT_BLACK); - tft.drawString(String("MODE: ") + controlModeName() + " " + armedCameraCount() + " ARMED", 8, 265, 2); + tft.drawString(String("TARGET: ") + dashboardTargetName() + " " + armedCameraCount() + "/" + + targetCameras + " ON", 8, 265, 2); drawDashboardFooter(); } drawBrightnessToast(); @@ -515,6 +492,10 @@ void clearBrightnessToast() { // restoring this small patch is enough. A full dashboard redraw here makes // the display visibly twitch just because a message has politely left. tft.fillRoundRect(49, 283, 72, 19, 4, TFT_BLACK); + if (renderedView == ScreenView::CAMERA_COUNT) drawFooterLabels("NEXT", "SELECT"); + else if (renderedView == ScreenView::CAMERA_LIST) drawFooterLabels("NEXT", "CONNECT"); + else if (renderedView == ScreenView::SINGLE_DASHBOARD || + renderedView == ScreenView::MULTI_DASHBOARD) drawDashboardFooter(); } void drawPowerOffCountdown(uint8_t seconds) { @@ -533,6 +514,48 @@ void restoreAfterPowerCountdown() { dirty = false; } +void drawScaledTitle(TFT_eSprite& destination, TFT_eSprite& source, + int sourceWidth, int sourceHeight, int centerX, int centerY, + uint16_t scalePermille, uint16_t color) { + const int width = max(1, (sourceWidth * scalePermille) / 1000); + const int height = max(1, (sourceHeight * scalePermille) / 1000); + const int left = centerX - width / 2; + const int top = centerY - height / 2; + + // Max-pool each source region. Thin bitmap-font strokes remain visible while + // the title is still far away instead of vanishing between sampled pixels. + for (int y = 0; y < height; ++y) { + const int sourceTop = (y * sourceHeight) / height; + const int sourceBottom = max(sourceTop + 1, ((y + 1) * sourceHeight + height - 1) / height); + for (int x = 0; x < width; ++x) { + const int sourceLeft = (x * sourceWidth) / width; + const int sourceRight = max(sourceLeft + 1, ((x + 1) * sourceWidth + width - 1) / width); + bool ink = false; + for (int sy = sourceTop; sy < min(sourceBottom, sourceHeight) && !ink; ++sy) { + for (int sx = sourceLeft; sx < min(sourceRight, sourceWidth); ++sx) { + if (source.readPixel(sx, sy) != TFT_BLACK) { + ink = true; + break; + } + } + } + if (ink && left + x >= 0 && left + x < 170 && top + y >= 0 && top + y < 320) { + destination.drawPixel(left + x, top + y, color); + } + } + } +} + +int cubicPoint(int start, int control1, int control2, int end, int tPermille) { + const int64_t t = tPermille; + const int64_t inverse = 1000 - t; + const int64_t value = (int64_t)start * inverse * inverse * inverse + + 3LL * control1 * inverse * inverse * t + + 3LL * control2 * inverse * t * t + + (int64_t)end * t * t * t; + return value / 1000000000LL; +} + bool playStartupAnimation(bool requireLeftHold) { // A short star-flight ends on the exact title position used by the camera // count screen. The next UI can therefore appear without a black transition. @@ -547,11 +570,28 @@ bool playStartupAnimation(bool requireLeftHold) { constexpr int8_t starDx[] = {-12, -9, -6, -3, 3, 6, 9, 12, -11, -7, -4, 4, 7, 11, -10, -5, 5, 10}; constexpr int8_t starDy[] = {-7, -12, -5, -11, -10, -5, -12, -7, 6, 11, 5, 5, 11, 6, 1, -2, 2, -1}; constexpr uint8_t starPhase[] = {0, 37, 71, 18, 52, 89, 28, 63, 9, 46, 82, 24, 58, 95, 33, 76, 14, 67}; - constexpr uint8_t frames = 25; + constexpr uint8_t frames = 30; constexpr uint16_t frameMs = STARTUP_ANIMATION_MS / frames; + TFT_eSprite title(&tft); + title.setColorDepth(8); + if (!title.createSprite(160, 38)) { + frame.deleteSprite(); + tft.fillScreen(TFT_BLACK); + Serial.println("DISPLAY: title sprite allocation failed"); + return !requireLeftHold || digitalRead(SELECT_BUTTON) == LOW; + } + title.fillSprite(TFT_BLACK); + title.setTextDatum(TL_DATUM); + title.setTextColor(TFT_WHITE, TFT_BLACK); + title.drawString("ALPHABOOP", 0, 0, 4); + const int titleWidth = title.textWidth("ALPHABOOP", 4); + const int titleHeight = title.fontHeight(4); + const int finalCenterX = 8 + titleWidth / 2; + const int finalCenterY = 12 + titleHeight / 2; for (uint8_t f = 0; f < frames; ++f) { if (requireLeftHold && digitalRead(SELECT_BUTTON) != LOW) { + title.deleteSprite(); frame.deleteSprite(); return false; } @@ -561,7 +601,7 @@ bool playStartupAnimation(bool requireLeftHold) { // the title move they thin out until the final frame is already the UI. if (f < frames - 1) { for (uint8_t i = 0; i < sizeof(starDx); ++i) { - if (f >= 16 && (i % 9) < f - 15) continue; + if (f >= 21 && (i % 8) < f - 20) continue; const int travel = (f * 11 + starPhase[i]) % 120; const int radius = 2 + (travel * travel) / 85; const int previousRadius = max(0, radius - 7 - travel / 24); @@ -576,22 +616,30 @@ bool playStartupAnimation(bool requireLeftHold) { } } - if (f >= 10 && f < 16) { - frame.setTextDatum(MC_DATUM); - frame.setTextColor(f < 12 ? TFT_DARKGREY : TFT_WHITE, TFT_BLACK); - frame.drawString("ALPHABOOP", 85, 150, 4); - } else if (f >= 16) { - const int move = f - 16; - const int x = 18 - (move * 10) / 8; - const int y = 140 - (move * 128) / 8; - frame.setTextDatum(TL_DATUM); - frame.setTextColor(TFT_WHITE, TFT_BLACK); - frame.drawString("ALPHABOOP", x, y, 4); + int titleX; + int titleY; + int titleScale; + if (f <= 14) { + const int progress = (f * 1000) / 14; + const int accelerated = (progress * progress) / 1000; + titleX = 85; + titleY = 190 - (40 * accelerated) / 1000; + titleScale = 180 + (900 * accelerated) / 1000; + } else { + const int progress = ((f - 14) * 1000) / 15; + titleX = cubicPoint(85, 85, 122, finalCenterX, progress); + titleY = cubicPoint(150, 137, 62, finalCenterY, progress); + titleScale = 1080 - (80 * progress) / 1000 + + (480 * progress * (1000 - progress)) / 1000000; } + const uint16_t titleColor = titleScale < 360 ? TFT_DARKGREY : + titleScale < 650 ? TFT_LIGHTGREY : TFT_WHITE; + drawScaledTitle(frame, title, titleWidth, titleHeight, titleX, titleY, titleScale, titleColor); frame.pushSprite(0, 0); delay(frameMs); } + title.deleteSprite(); frame.deleteSprite(); tft.setTextDatum(TL_DATUM); return !requireLeftHold || digitalRead(SELECT_BUTTON) == LOW; @@ -728,7 +776,10 @@ class ClientEvents : public BLEClientCallbacks { camera.shutterActive = false; camera.buttonReleasePending = false; camera.photoStep = PhotoStep::IDLE; - if (connectingSlot == slot_) connectingSlot = -1; + if (connectingSlot == slot_) { + connectingSlot = -1; + connectingStartedAt = 0; + } status = camera.pairingFailed ? "OPEN CAMERA: PAIRING" : String("C") + (slot_ + 1) + " DISCONNECTED"; Serial.printf("C%d BLE: disconnected; pairing_failed=%d\n", slot_ + 1, camera.pairingFailed); dirty = true; @@ -815,7 +866,10 @@ bool discoverSonyService(uint8_t index) { camera.notifyReadable = camera.notify->canRead(); camera.ready = true; camera.notify->registerForNotify(statusNotify); - if (connectingSlot == index) connectingSlot = -1; + if (connectingSlot == index) { + connectingSlot = -1; + connectingStartedAt = 0; + } status = String("C") + (index + 1) + " ALPHABOOP LINK"; Serial.printf("C%d SONY: FF01/FF02 ready; FF02 read=%d notify=%d\n", index + 1, camera.notifyReadable, camera.notify->canNotify()); @@ -823,7 +877,7 @@ bool discoverSonyService(uint8_t index) { else Serial.printf("C%d FF02: notify-only; waiting for camera truth\n", index + 1); if (uiState == UiState::SELECT_CAMERAS && allCamerasLinked()) { uiState = UiState::DASHBOARD; - controlMode = ControlMode::REC; + dashboardTarget = targetCameras == 1 ? 0 : -1; Serial.println("UI: initial camera set ready; dashboard locked in"); } dirty = true; @@ -883,29 +937,6 @@ void startPhotoSequenceArmed() { } } -void runSelectedAction() { - switch (controlMode) { - case ControlMode::REC: sendRecordClickArmed(false, "STOP"); break; - case ControlMode::TARGETS: - uiState = UiState::TARGET_EDITOR; - targetEditorCursor = 0; - dirty = true; - break; - case ControlMode::AF_ON: sendMomentaryButtonArmed("AF-ON", 0x15, 0x14); break; - case ControlMode::PHOTO: startPhotoSequenceArmed(); break; - case ControlMode::C1: sendMomentaryButtonArmed("C1", 0x21, 0x20); break; - } -} - -void nextControlMode() { - controlMode = controlMode == ControlMode::REC ? (targetCameras > 1 ? ControlMode::TARGETS : ControlMode::AF_ON) : - controlMode == ControlMode::TARGETS ? ControlMode::AF_ON : - controlMode == ControlMode::AF_ON ? ControlMode::PHOTO : - controlMode == ControlMode::PHOTO ? ControlMode::C1 : ControlMode::REC; - Serial.printf("MODE: %d\n", (int)controlMode); - dirty = true; -} - int firstFreeSlot() { for (uint8_t i = 0; i < targetCameras; ++i) if (!cameras[i].desired) return i; return -1; @@ -944,11 +975,17 @@ void beginManualSelection() { camera.name = String("CAMERA ") + (i + 1); } selected = 0; + dashboardTarget = targetCameras == 1 ? 0 : -1; } bool connectDeviceToSlot(uint8_t slot, int deviceIndex) { if (connectingSlot >= 0 || deviceIndex < 0 || deviceIndex >= (int)devices.size()) return false; - const Device& device = devices[deviceIndex]; + BLEDevice::getScan()->stop(); + scanInProgress = false; + // The callback is now quiet, so no insertion can invalidate this copy while + // BLE begins the connection. + if (deviceIndex >= (int)devices.size()) return false; + const Device device = devices[deviceIndex]; if (!device.sony) { status = "SELECT SONY CAMERA"; dirty = true; return false; } Camera& camera = cameras[slot]; camera.name = device.name; @@ -962,8 +999,7 @@ bool connectDeviceToSlot(uint8_t slot, int deviceIndex) { status = String("C") + (slot + 1) + " CONNECTING..."; dirty = true; connectingSlot = slot; - BLEDevice::getScan()->stop(); - scanInProgress = false; + connectingStartedAt = millis(); if (!camera.client) { camera.client = BLEDevice::createClient(); camera.client->setClientCallbacks(new ClientEvents(slot)); @@ -972,6 +1008,7 @@ bool connectDeviceToSlot(uint8_t slot, int deviceIndex) { BLEAddress address(device.address.c_str()); if (!camera.client->connect(address)) { connectingSlot = -1; + connectingStartedAt = 0; camera.transportConnected = false; status = "LINK FAILED"; dirty = true; @@ -994,8 +1031,28 @@ bool connectSelected() { return connectDeviceToSlot(slot, selected); } +void processConnectionTimeout() { + if (connectingSlot < 0 || !connectingStartedAt || + millis() - connectingStartedAt < CONNECT_TIMEOUT_MS) return; + const int slot = connectingSlot; + Camera& camera = cameras[slot]; + connectingSlot = -1; + connectingStartedAt = 0; + status = String("C") + (slot + 1) + " LINK TIMEOUT"; + Serial.printf("C%d CONNECT: timed out after %lu ms\n", slot + 1, CONNECT_TIMEOUT_MS); + if (camera.client && camera.client->isConnected()) { + camera.client->disconnect(); + } else { + camera.transportConnected = false; + camera.securityComplete = false; + camera.securityOK = false; + camera.serviceDiscoveryStarted = false; + } + dirty = true; +} + void attemptNextReconnect() { - if ((uiState != UiState::DASHBOARD && uiState != UiState::TARGET_EDITOR) || connectingSlot >= 0) return; + if (uiState != UiState::DASHBOARD || connectingSlot >= 0) return; for (uint8_t i = 0; i < targetCameras; ++i) { Camera& camera = cameras[i]; if (!camera.desired || camera.ready || camera.transportConnected) continue; @@ -1010,7 +1067,7 @@ void attemptNextReconnect() { } void pollOneReadableStatus() { - if (uiState != UiState::DASHBOARD && uiState != UiState::TARGET_EDITOR) return; + if (uiState != UiState::DASHBOARD) return; for (uint8_t checked = 0; checked < targetCameras; ++checked) { const uint8_t index = (statusReadCursor + checked) % targetCameras; Camera& camera = cameras[index]; @@ -1107,17 +1164,20 @@ void handleSerialCommand() { String input = Serial.readStringUntil('\n'); input.trim(); input.toUpperCase(); if (input == "REC") sendRecordClickArmed(!anyArmedRecording(), "SERIAL"); + else if (input == "START") sendRecordClickArmed(true, "SERIAL START"); + else if (input == "STOP") sendRecordClickArmed(false, "SERIAL STOP"); else if (input == "AF") sendMomentaryButtonArmed("AF-ON", 0x15, 0x14); else if (input == "PHOTO") startPhotoSequenceArmed(); else if (input == "C1") sendMomentaryButtonArmed("C1", 0x21, 0x20); - else if (input == "MODE") nextControlMode(); + else if (input == "NEXT") cycleDashboardTarget(); + else if (input == "TOGGLE") toggleDashboardTarget(); else if (input == "STATUS") { for (uint8_t i = 0; i < targetCameras; ++i) { Serial.printf("C%d desired=%d armed=%d ready=%d rec_known=%d rec=%d addr=%s\n", i + 1, cameras[i].desired, cameras[i].armed, cameras[i].ready, cameras[i].recordingKnown, cameras[i].recording, cameras[i].address.c_str()); } - } else Serial.println("SERIAL COMMAND: REC, AF, PHOTO, C1, MODE, STATUS"); + } else Serial.println("SERIAL COMMAND: START, STOP, REC, NEXT, TOGGLE, AF, PHOTO, C1, STATUS"); } void setup() { @@ -1168,6 +1228,7 @@ void setup() { security->setRespEncryptionKey(ESP_BLE_ENC_KEY_MASK | ESP_BLE_ID_KEY_MASK); BLEDevice::getScan()->setAdvertisedDeviceCallbacks(new Scanner()); BLEDevice::getScan()->setActiveScan(true); + devices.reserve(32); } void loop() { @@ -1191,52 +1252,84 @@ void loop() { return; } - // LEFT owns navigation and the display/power gesture. A short navigation - // event fires only on release, so a hold cannot also change a selection. + // LEFT selects/toggles command targets. RIGHT starts/stops REC. Holding both + // is the deliberately rarer brightness/power gesture. if (selectPressed) { selectHeld = true; selectLongHandled = false; - powerOffCountdownActive = false; - powerOffSecondsShown = 0; selectPressedAt = millis(); } - if (selectHeld && !selectKey.stable && !selectLongHandled && - millis() - selectPressedAt >= BRIGHTNESS_HOLD_MS) { - cycleBrightness(); - selectLongHandled = true; + if (actionPressed) { + actionHeld = true; + actionLongHandled = false; + actionPressedAt = millis(); } - if (selectHeld && !selectKey.stable && millis() - selectPressedAt >= POWER_COUNTDOWN_START_MS) { - const uint32_t heldFor = millis() - selectPressedAt; - if (heldFor >= POWER_OFF_HOLD_MS) enterDeepSleep(); - const uint8_t seconds = 2 - ((heldFor - POWER_COUNTDOWN_START_MS) / 1000); - if (!powerOffCountdownActive) { - powerOffCountdownActive = true; - brightnessToastUntil = 0; - clearBrightnessToast(); - } - if (seconds != powerOffSecondsShown) { - powerOffSecondsShown = seconds; - drawPowerOffCountdown(seconds); + + if (suppressButtonsUntilReleased) { + if (selectKey.stable && actionKey.stable) { + suppressButtonsUntilReleased = false; + selectHeld = false; + actionHeld = false; } - } - if (selectHeld && selectKey.stable) { - if (!selectLongHandled) selectShortReleased = true; - if (powerOffCountdownActive) restoreAfterPowerCountdown(); + } else if (!powerChordActive && !selectKey.stable && !actionKey.stable && + !selectLongHandled && !actionLongHandled) { + powerChordActive = true; + powerChordBrightnessHandled = false; + powerChordStartedAt = millis(); powerOffCountdownActive = false; powerOffSecondsShown = 0; - selectHeld = false; + Serial.println("BUTTONS: power chord"); } - // RIGHT owns confirmation and camera actions. REC uses the duration; other - // actions are deliberately dispatched on release. - if (actionPressed) { - actionHeld = true; - actionLongHandled = false; - actionPressedAt = millis(); - } - if (actionHeld && actionKey.stable) { - if (!actionLongHandled) actionShortReleased = true; - actionHeld = false; + if (powerChordActive) { + const uint32_t heldFor = millis() - powerChordStartedAt; + if (!powerChordBrightnessHandled && heldFor >= BRIGHTNESS_HOLD_MS) { + cycleBrightness(); + powerChordBrightnessHandled = true; + } + if (heldFor >= POWER_COUNTDOWN_START_MS) { + if (heldFor >= POWER_OFF_HOLD_MS) enterDeepSleep(); + const uint8_t seconds = 2 - ((heldFor - POWER_COUNTDOWN_START_MS) / 1000); + if (!powerOffCountdownActive) { + powerOffCountdownActive = true; + brightnessToastUntil = 0; + clearBrightnessToast(); + } + if (seconds != powerOffSecondsShown) { + powerOffSecondsShown = seconds; + drawPowerOffCountdown(seconds); + } + } + if (selectKey.stable || actionKey.stable) { + if (powerOffCountdownActive) restoreAfterPowerCountdown(); + powerChordActive = false; + powerOffCountdownActive = false; + powerOffSecondsShown = 0; + selectHeld = false; + actionHeld = false; + selectLongHandled = true; + actionLongHandled = true; + suppressButtonsUntilReleased = true; + } + } else if (!suppressButtonsUntilReleased) { + if (selectHeld && !selectKey.stable && !selectLongHandled && + millis() - selectPressedAt >= TARGET_TOGGLE_HOLD_MS) { + if (uiState == UiState::DASHBOARD) toggleDashboardTarget(); + selectLongHandled = true; + } + if (actionHeld && !actionKey.stable && !actionLongHandled && + millis() - actionPressedAt >= STOP_HOLD_MS) { + if (uiState == UiState::DASHBOARD) sendRecordClickArmed(false, "HOLD STOP"); + actionLongHandled = true; + } + if (selectHeld && selectKey.stable) { + if (!selectLongHandled) selectShortReleased = true; + selectHeld = false; + } + if (actionHeld && actionKey.stable) { + if (!actionLongHandled) actionShortReleased = true; + actionHeld = false; + } } if (powerOffCountdownActive) { @@ -1269,6 +1362,7 @@ void loop() { return; } + processConnectionTimeout(); for (uint8_t i = 0; i < targetCameras; ++i) { Camera& camera = cameras[i]; if (camera.transportConnected && camera.securityComplete && camera.securityOK && !camera.ready && !camera.serviceDiscoveryStarted) { @@ -1286,35 +1380,9 @@ void loop() { dirty = true; } if (actionShortReleased) connectSelected(); - } else if (uiState == UiState::TARGET_EDITOR) { - if (selectShortReleased) { - targetEditorCursor = (targetEditorCursor + 1) % (targetCameras + 1); - dirty = true; - } - if (actionShortReleased) { - if (targetEditorCursor < targetCameras) { - Camera& camera = cameras[targetEditorCursor]; - camera.armed = !camera.armed; - status = String("C") + (targetEditorCursor + 1) + (camera.armed ? " ARMED" : " MUTED"); - Serial.printf("TARGETS: C%d %s\n", targetEditorCursor + 1, camera.armed ? "ARMED" : "MUTED"); - } else { - uiState = UiState::DASHBOARD; - controlMode = ControlMode::REC; - Serial.printf("TARGETS: done; %u/%u armed\n", armedCameraCount(), targetCameras); - } - dirty = true; - } } else if (uiState == UiState::DASHBOARD) { - if (selectShortReleased) nextControlMode(); - if (controlMode == ControlMode::REC && actionHeld && !actionKey.stable && - !actionLongHandled && millis() - actionPressedAt >= STOP_HOLD_MS) { - sendRecordClickArmed(false, "HOLD STOP"); - actionLongHandled = true; - } - if (actionShortReleased) { - if (controlMode == ControlMode::REC) sendRecordClickArmed(true, "TAP START"); - else runSelectedAction(); - } + if (selectShortReleased) cycleDashboardTarget(); + if (actionShortReleased) sendRecordClickArmed(true, "TAP START"); } processCameraTimers(); if (anyRecording() && millis() - lastTimer > 1000) { diff --git a/sony-tally/tools/send_serial_command.py b/sony-tally/tools/send_serial_command.py index a666097..59613b8 100644 --- a/sony-tally/tools/send_serial_command.py +++ b/sony-tally/tools/send_serial_command.py @@ -6,7 +6,10 @@ import time parser = argparse.ArgumentParser() -parser.add_argument("command", choices=["REC", "AF", "PHOTO", "C1", "MODE", "STATUS"]) +parser.add_argument( + "command", + choices=["START", "STOP", "REC", "NEXT", "TOGGLE", "AF", "PHOTO", "C1", "STATUS"], +) parser.add_argument("--port", default="/dev/cu.usbmodem2101") args = parser.parse_args() From b70149445b87360a5a27a192eee8521bbfc53c78 Mon Sep 17 00:00:00 2001 From: iffun Date: Wed, 29 Jul 2026 23:58:37 +0300 Subject: [PATCH 4/5] fix: align startup and restore left power hold --- README.md | 6 +- sony-tally/docs/control-design-v2.md | 21 ++-- sony-tally/docs/revision-2026-07-29.md | 16 +-- sony-tally/src/main.cpp | 132 +++++++++---------------- 4 files changed, 73 insertions(+), 102 deletions(-) diff --git a/README.md b/README.md index 47cedb9..f287a2d 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,9 @@ Alphaboop runs on a [LILYGO T-Display-S3](https://www.lilygo.cc/products/t-displ - Provides a two-button interface: - **left**: tap cycles `ALL → C1 → C2 → C3 → C4`; hold toggles the selected target; - **right**: tap starts armed cameras; hold stops armed cameras; - - **both together**: hold for 0.5 s to cycle display brightness `100% → 50% → MIN`; keep holding for the power-off countdown. + - **left, held longer**: at 1.2 s cycles display brightness `100% → 50% → MIN`; at 2 s starts the power-off countdown. - Shows a large single-camera dashboard or dedicated layouts for two, three, and four cameras. -- Hold **both** buttons for 3.5 seconds to sleep. Brightness changes at 0.5 seconds, then a full-screen green `POWER OFF 2 → 1` appears; releasing either button cancels. To wake, hold the **left** button through the one-second star-flight animation. Cold boot gets the same production. Reset is still reset. It has paperwork. +- Hold the **left** button for 4 seconds to sleep. Brightness changes at 1.2 seconds, then a full-screen green `POWER OFF 2 → 1` appears after 2 seconds; releasing the button cancels. To wake, hold the **left** button through the one-second star-flight animation. Cold boot gets the same production. Reset is still reset. It has paperwork. The camera remains the authority. It has earned this privilege. @@ -35,7 +35,7 @@ The camera remains the authority. It has earned this privilege. Targets are edited directly on the dashboard: 1. Tap left to move through `ALL`, C1…Cn. -2. Hold left to toggle that camera. +2. Hold left for roughly 0.65–1.2 seconds, then release, to toggle that camera. 3. On `ALL`, holding left arms every camera if any are off, or mutes all if all are already armed. 4. Tap or hold right for REC start/stop without leaving the dashboard. diff --git a/sony-tally/docs/control-design-v2.md b/sony-tally/docs/control-design-v2.md index f31e483..e4e08b8 100644 --- a/sony-tally/docs/control-design-v2.md +++ b/sony-tally/docs/control-design-v2.md @@ -12,12 +12,13 @@ The labels follow the buttons' positions on the front of the T-Display-S3. | --- | --- | --- | | Camera count | Next option | Select | | Camera discovery | Next device | Connect/select | -| Dashboard | Tap: next target; hold: toggle target | Tap: REC start; hold: REC stop | -| Any powered-on screen | Hold both: DIM, then power-off countdown | Hold both: same chord | +| Dashboard | Tap: next target; medium hold: toggle target; long hold: DIM/power | Tap: REC start; hold: REC stop | +| Any powered-on screen | At 1.2 s: DIM; at 2 s: power-off countdown; at 4 s: sleep | REC only on dashboard | | Deep sleep | Hold through the one-second animation to wake | No action | -Brightness and power require both buttons, so neither production control shares -a long-press threshold with power. +The left-button timeline deliberately has separate windows: a tap is shorter +than 0.65 seconds, target toggle happens on release between 0.65 and 1.2 +seconds, and reaching 1.2 seconds enters the device-level DIM/power path. ## Selecting command targets @@ -32,7 +33,8 @@ All configured cameras remain visible. `armed` is independent of `connected`: Target selection stays on the dashboard: 1. Tap left to cycle `ALL → C1 → C2 → C3 → C4 → ALL`. -2. Hold left to toggle the highlighted camera. +2. Hold left for roughly 0.65–1.2 seconds, then release, to toggle the + highlighted camera. 3. Holding on `ALL` arms all when any are off, or mutes all when all are on. Zero armed cameras is allowed. An action then reports `NO TARGETS` and sends @@ -86,8 +88,9 @@ Cold boot and deep-sleep wake use the same one-second, code-rendered 8-bit sequence: 1. stars accelerate from the centre into short perspective streaks; -2. the flight arrives at `ALPHABOOP`; -3. the title moves into the exact header position of the next screen; +2. the small title approaches on the fixed vertical centre line; +3. without stopping or changing X, the title flies upward into the exact + header position of the next screen; 4. the remaining UI draws without clearing that final frame. Deep-sleep wake requires the left button to remain held for the whole @@ -111,5 +114,5 @@ Run this before merging the branch into the stable release: changing its armed state. 7. Connect Alphaboop while a camera is already recording. Record whether FF02 provides a snapshot, is readable, or remains unknown; keep the serial log. -8. Hold both through DIM and cancel the power countdown by releasing either - button, then complete it. Neither individual button may change brightness. +8. Hold left through DIM and cancel the power countdown by releasing it, then + complete the four-second hold and wake again with left. diff --git a/sony-tally/docs/revision-2026-07-29.md b/sony-tally/docs/revision-2026-07-29.md index 266db4c..69f08d2 100644 --- a/sony-tally/docs/revision-2026-07-29.md +++ b/sony-tally/docs/revision-2026-07-29.md @@ -6,11 +6,13 @@ This is the regression review performed after the first four-camera field test. - Removed the front-panel `AF-ON`, `PHOTO`, `C1`, and mode carousel. The production UI now spends both buttons on target selection and REC. -- Replaced the startup title's mixed centre/top-left coordinates with one - continuously scaled bitmap and a cubic path that ends at the real header - coordinates. -- Moved DIM and power-off to a two-button chord so target toggle and REC stop - do not share a long-press gesture with power. +- Replaced the startup title's mixed coordinates and curved path with one + continuously scaled bitmap travelling only on the screen's vertical centre + line. It approaches, passes the centre, and reaches the real header without + changing X. +- Restored DIM and power-off to the left-button hold timeline: target toggle + occupies the medium-release window, DIM begins at 1.2 seconds, the countdown + at 2 seconds, and sleep at 4 seconds. The right button remains REC-only. - Restored footer labels after the DIM toast disappears. The toast previously overlapped the top pixels of the right-hand label. - Added a 15-second connection timeout. A security or GATT attempt can no @@ -32,7 +34,7 @@ This is the regression review performed after the first four-camera field test. - Timer refreshes redraw only timer/row regions, not the whole display. - Wake requires the left button through the animation, then waits for release before accepting REC input. -- Releasing either button cancels the power chord countdown. +- Releasing left cancels the power countdown. ## Protocol limits, not firmware defects @@ -45,7 +47,7 @@ This is the regression review performed after the first four-camera field test. ## Hardware regression still required 1. Observe the complete title path on the real 170 × 320 panel. -2. Test tap/hold/chord separation with both physical buttons. +2. Test the left tap/medium-hold/DIM/power timeline and the right REC tap/hold. 3. Build target sets `C1`, `C1+C3`, none, and all. 4. Start a mixed group where one camera is already recording. 5. Power-cycle one camera and confirm automatic return to its original slot. diff --git a/sony-tally/src/main.cpp b/sony-tally/src/main.cpp index 529e7df..b56c88c 100644 --- a/sony-tally/src/main.cpp +++ b/sony-tally/src/main.cpp @@ -15,9 +15,9 @@ constexpr int SINGLE_LEFT = 6; constexpr uint8_t MAX_CAMERAS = 4; constexpr uint32_t STOP_HOLD_MS = 700; constexpr uint32_t TARGET_TOGGLE_HOLD_MS = 650; -constexpr uint32_t BRIGHTNESS_HOLD_MS = 500; -constexpr uint32_t POWER_COUNTDOWN_START_MS = 1500; -constexpr uint32_t POWER_OFF_HOLD_MS = 3500; +constexpr uint32_t BRIGHTNESS_HOLD_MS = 1200; +constexpr uint32_t POWER_COUNTDOWN_START_MS = 2000; +constexpr uint32_t POWER_OFF_HOLD_MS = 4000; constexpr uint32_t STARTUP_ANIMATION_MS = 1050; constexpr uint32_t RECONNECT_COOLDOWN_MS = 5000; constexpr uint32_t STATUS_READ_INTERVAL_MS = 5000; @@ -103,10 +103,6 @@ bool actionHeld = false, actionLongHandled = false; uint32_t actionPressedAt = 0; bool selectHeld = false, selectLongHandled = false; uint32_t selectPressedAt = 0; -bool powerChordActive = false; -bool powerChordBrightnessHandled = false; -bool suppressButtonsUntilReleased = false; -uint32_t powerChordStartedAt = 0; bool powerOffCountdownActive = false; uint8_t powerOffSecondsShown = 0; bool inputsArmed = false; @@ -231,7 +227,9 @@ void drawCountOptions() { void drawCountSelectionScreen() { tft.setTextColor(TFT_WHITE, TFT_BLACK); - tft.drawString("ALPHABOOP", 8, 12, 4); + tft.setTextDatum(TC_DATUM); + tft.drawString("ALPHABOOP", 85, 12, 4); + tft.setTextDatum(TL_DATUM); tft.setTextColor(TFT_ORANGE, TFT_BLACK); tft.drawString("by @iffun", 8, 42, 2); tft.setTextColor(TFT_LIGHTGREY, TFT_BLACK); @@ -546,16 +544,6 @@ void drawScaledTitle(TFT_eSprite& destination, TFT_eSprite& source, } } -int cubicPoint(int start, int control1, int control2, int end, int tPermille) { - const int64_t t = tPermille; - const int64_t inverse = 1000 - t; - const int64_t value = (int64_t)start * inverse * inverse * inverse + - 3LL * control1 * inverse * inverse * t + - 3LL * control2 * inverse * t * t + - (int64_t)end * t * t * t; - return value / 1000000000LL; -} - bool playStartupAnimation(bool requireLeftHold) { // A short star-flight ends on the exact title position used by the camera // count screen. The next UI can therefore appear without a black transition. @@ -586,7 +574,6 @@ bool playStartupAnimation(bool requireLeftHold) { title.drawString("ALPHABOOP", 0, 0, 4); const int titleWidth = title.textWidth("ALPHABOOP", 4); const int titleHeight = title.fontHeight(4); - const int finalCenterX = 8 + titleWidth / 2; const int finalCenterY = 12 + titleHeight / 2; for (uint8_t f = 0; f < frames; ++f) { @@ -616,21 +603,20 @@ bool playStartupAnimation(bool requireLeftHold) { } } - int titleX; - int titleY; + // One uninterrupted vertical fly-by. X stays on the screen centre for + // every frame: the title approaches from far away, reaches full size near + // the middle, then keeps travelling upward into its final header position. + const int progress = (f * 1000) / (frames - 1); + const int titleX = 85; + const int titleY = 205 - + ((205 - finalCenterY) * progress * progress) / 1000000; int titleScale; - if (f <= 14) { - const int progress = (f * 1000) / 14; - const int accelerated = (progress * progress) / 1000; - titleX = 85; - titleY = 190 - (40 * accelerated) / 1000; - titleScale = 180 + (900 * accelerated) / 1000; + if (progress <= 550) { + const int approach = (progress * 1000) / 550; + titleScale = 180 + (940 * approach * approach) / 1000000; } else { - const int progress = ((f - 14) * 1000) / 15; - titleX = cubicPoint(85, 85, 122, finalCenterX, progress); - titleY = cubicPoint(150, 137, 62, finalCenterY, progress); - titleScale = 1080 - (80 * progress) / 1000 + - (480 * progress * (1000 - progress)) / 1000000; + const int settle = ((progress - 550) * 1000) / 450; + titleScale = 1120 - (120 * settle) / 1000; } const uint16_t titleColor = titleScale < 360 ? TFT_DARKGREY : titleScale < 650 ? TFT_LIGHTGREY : TFT_WHITE; @@ -1252,12 +1238,15 @@ void loop() { return; } - // LEFT selects/toggles command targets. RIGHT starts/stops REC. Holding both - // is the deliberately rarer brightness/power gesture. + // LEFT owns navigation and the device-level hold timeline. RIGHT owns REC. + // Keeping power on one button makes the controls usable without discovering + // a secret handshake between two pieces of orange plastic. if (selectPressed) { selectHeld = true; selectLongHandled = false; selectPressedAt = millis(); + powerOffCountdownActive = false; + powerOffSecondsShown = 0; } if (actionPressed) { actionHeld = true; @@ -1265,27 +1254,11 @@ void loop() { actionPressedAt = millis(); } - if (suppressButtonsUntilReleased) { - if (selectKey.stable && actionKey.stable) { - suppressButtonsUntilReleased = false; - selectHeld = false; - actionHeld = false; - } - } else if (!powerChordActive && !selectKey.stable && !actionKey.stable && - !selectLongHandled && !actionLongHandled) { - powerChordActive = true; - powerChordBrightnessHandled = false; - powerChordStartedAt = millis(); - powerOffCountdownActive = false; - powerOffSecondsShown = 0; - Serial.println("BUTTONS: power chord"); - } - - if (powerChordActive) { - const uint32_t heldFor = millis() - powerChordStartedAt; - if (!powerChordBrightnessHandled && heldFor >= BRIGHTNESS_HOLD_MS) { + if (selectHeld && !selectKey.stable) { + const uint32_t heldFor = millis() - selectPressedAt; + if (!selectLongHandled && heldFor >= BRIGHTNESS_HOLD_MS) { cycleBrightness(); - powerChordBrightnessHandled = true; + selectLongHandled = true; } if (heldFor >= POWER_COUNTDOWN_START_MS) { if (heldFor >= POWER_OFF_HOLD_MS) enterDeepSleep(); @@ -1300,36 +1273,29 @@ void loop() { drawPowerOffCountdown(seconds); } } - if (selectKey.stable || actionKey.stable) { - if (powerOffCountdownActive) restoreAfterPowerCountdown(); - powerChordActive = false; - powerOffCountdownActive = false; - powerOffSecondsShown = 0; - selectHeld = false; - actionHeld = false; - selectLongHandled = true; - actionLongHandled = true; - suppressButtonsUntilReleased = true; - } - } else if (!suppressButtonsUntilReleased) { - if (selectHeld && !selectKey.stable && !selectLongHandled && - millis() - selectPressedAt >= TARGET_TOGGLE_HOLD_MS) { - if (uiState == UiState::DASHBOARD) toggleDashboardTarget(); - selectLongHandled = true; - } - if (actionHeld && !actionKey.stable && !actionLongHandled && - millis() - actionPressedAt >= STOP_HOLD_MS) { - if (uiState == UiState::DASHBOARD) sendRecordClickArmed(false, "HOLD STOP"); - actionLongHandled = true; - } - if (selectHeld && selectKey.stable) { - if (!selectLongHandled) selectShortReleased = true; - selectHeld = false; - } - if (actionHeld && actionKey.stable) { - if (!actionLongHandled) actionShortReleased = true; - actionHeld = false; + } + if (actionHeld && !actionKey.stable && !actionLongHandled && + millis() - actionPressedAt >= STOP_HOLD_MS) { + if (uiState == UiState::DASHBOARD) sendRecordClickArmed(false, "HOLD STOP"); + actionLongHandled = true; + } + if (selectHeld && selectKey.stable) { + const uint32_t heldFor = millis() - selectPressedAt; + if (powerOffCountdownActive) restoreAfterPowerCountdown(); + powerOffCountdownActive = false; + powerOffSecondsShown = 0; + if (!selectLongHandled) { + if (uiState == UiState::DASHBOARD && heldFor >= TARGET_TOGGLE_HOLD_MS) { + toggleDashboardTarget(); + } else if (heldFor < TARGET_TOGGLE_HOLD_MS) { + selectShortReleased = true; + } } + selectHeld = false; + } + if (actionHeld && actionKey.stable) { + if (!actionLongHandled) actionShortReleased = true; + actionHeld = false; } if (powerOffCountdownActive) { From b22c63c4b6b7a325d82fe488258c1c276747faa3 Mon Sep 17 00:00:00 2001 From: iffun Date: Thu, 30 Jul 2026 00:05:16 +0300 Subject: [PATCH 5/5] tune: shorten startup animation --- README.md | 2 +- sony-tally/docs/control-design-v2.md | 4 ++-- sony-tally/src/main.cpp | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f287a2d..b3b564f 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Alphaboop runs on a [LILYGO T-Display-S3](https://www.lilygo.cc/products/t-displ - **right**: tap starts armed cameras; hold stops armed cameras; - **left, held longer**: at 1.2 s cycles display brightness `100% → 50% → MIN`; at 2 s starts the power-off countdown. - Shows a large single-camera dashboard or dedicated layouts for two, three, and four cameras. -- Hold the **left** button for 4 seconds to sleep. Brightness changes at 1.2 seconds, then a full-screen green `POWER OFF 2 → 1` appears after 2 seconds; releasing the button cancels. To wake, hold the **left** button through the one-second star-flight animation. Cold boot gets the same production. Reset is still reset. It has paperwork. +- Hold the **left** button for 4 seconds to sleep. Brightness changes at 1.2 seconds, then a full-screen green `POWER OFF 2 → 1` appears after 2 seconds; releasing the button cancels. To wake, hold the **left** button through the 0.7-second star-flight animation. Cold boot gets the same production. Reset is still reset. It has paperwork. The camera remains the authority. It has earned this privilege. diff --git a/sony-tally/docs/control-design-v2.md b/sony-tally/docs/control-design-v2.md index e4e08b8..f4b3788 100644 --- a/sony-tally/docs/control-design-v2.md +++ b/sony-tally/docs/control-design-v2.md @@ -14,7 +14,7 @@ The labels follow the buttons' positions on the front of the T-Display-S3. | Camera discovery | Next device | Connect/select | | Dashboard | Tap: next target; medium hold: toggle target; long hold: DIM/power | Tap: REC start; hold: REC stop | | Any powered-on screen | At 1.2 s: DIM; at 2 s: power-off countdown; at 4 s: sleep | REC only on dashboard | -| Deep sleep | Hold through the one-second animation to wake | No action | +| Deep sleep | Hold through the 0.7-second animation to wake | No action | The left-button timeline deliberately has separate windows: a tap is shorter than 0.65 seconds, target toggle happens on release between 0.65 and 1.2 @@ -84,7 +84,7 @@ The UI must never imply that a locally counted timer is camera timecode. ## Unified startup animation -Cold boot and deep-sleep wake use the same one-second, code-rendered 8-bit +Cold boot and deep-sleep wake use the same 0.7-second, code-rendered 8-bit sequence: 1. stars accelerate from the centre into short perspective streaks; diff --git a/sony-tally/src/main.cpp b/sony-tally/src/main.cpp index b56c88c..6beb4c4 100644 --- a/sony-tally/src/main.cpp +++ b/sony-tally/src/main.cpp @@ -18,7 +18,7 @@ constexpr uint32_t TARGET_TOGGLE_HOLD_MS = 650; constexpr uint32_t BRIGHTNESS_HOLD_MS = 1200; constexpr uint32_t POWER_COUNTDOWN_START_MS = 2000; constexpr uint32_t POWER_OFF_HOLD_MS = 4000; -constexpr uint32_t STARTUP_ANIMATION_MS = 1050; +constexpr uint32_t STARTUP_ANIMATION_MS = 700; constexpr uint32_t RECONNECT_COOLDOWN_MS = 5000; constexpr uint32_t STATUS_READ_INTERVAL_MS = 5000; constexpr uint32_t CONNECT_TIMEOUT_MS = 15000;