diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16595e0..0b1be39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,8 +15,8 @@ jobs: target: daemon-mac - os: macos-14 target: linkctl-cli-mac - # - os: windows-latest - # target: daemon-win + - os: windows-latest + target: panel-win # - os: ubuntu-latest # target: firmware @@ -60,3 +60,18 @@ jobs: with: name: linkctl-${{ matrix.os }} path: host/linkctl/build/linkctl + + # --- linkctl-panel (Windows) — no external dependencies --- + - name: Build linkctl-panel (Windows) + if: matrix.target == 'panel-win' + working-directory: host/linkctl-panel + run: | + cmake -B build + cmake --build build --config Release + + - name: Upload linkctl-panel artifact + if: matrix.target == 'panel-win' + uses: actions/upload-artifact@v4 + with: + name: linkctl-panel-${{ matrix.os }} + path: host/linkctl-panel/build/Release/linkctl-panel.exe diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3e2aa63..3db612f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,9 +19,9 @@ jobs: - os: macos-14 target: linkctl-cli-mac arch: arm64 - # - os: windows-latest - # target: daemon-win - # arch: x64 + - os: windows-latest + target: panel-win + arch: x64 runs-on: ${{ matrix.os }} @@ -30,6 +30,7 @@ jobs: - name: Extract version from tag id: version + shell: bash run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" # --- daemon-mac --- @@ -92,6 +93,33 @@ jobs: name: release-${{ matrix.target }} path: ${{ steps.package-cli.outputs.ARCHIVE }} + # --- linkctl-panel (Windows) — no external dependencies --- + - name: Build linkctl-panel (Windows) + if: matrix.target == 'panel-win' + working-directory: host/linkctl-panel + run: | + cmake -B build + cmake --build build --config Release + + - name: Package linkctl-panel release (Windows) + if: matrix.target == 'panel-win' + id: package-panel + shell: bash + run: | + STAGING="linkctl-panel-${{ steps.version.outputs.VERSION }}-windows-${{ matrix.arch }}" + mkdir -p "$STAGING" + cp host/linkctl-panel/build/Release/linkctl-panel.exe "$STAGING/" + cp host/linkctl-panel/README.md "$STAGING/" + 7z a "${STAGING}.zip" "$STAGING" + echo "ARCHIVE=${STAGING}.zip" >> "$GITHUB_OUTPUT" + + - name: Upload linkctl-panel artifact + if: matrix.target == 'panel-win' + uses: actions/upload-artifact@v4 + with: + name: release-${{ matrix.target }} + path: ${{ steps.package-panel.outputs.ARCHIVE }} + create-release: needs: build-release runs-on: ubuntu-latest @@ -108,5 +136,7 @@ jobs: - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: - files: "*.tar.gz" + files: | + *.tar.gz + *.zip generate_release_notes: true diff --git a/CLAUDE.md b/CLAUDE.md index 919c467..7282326 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,8 +9,12 @@ ESP32-based joystick controller for Insta360 Link webcam. Controls pan/tilt/zoom ## Architecture ``` +macOS: [Insta360 Link] --USB/UVC--> [linkctl-daemon (C, libuvc)] --WiFi/WebSocket--> [ESP32 + Joystick] --TCP/9001--------> [linkctl CLI] +Windows: +[Insta360 Link] --USB/UVC--> [linkctl-panel (C, Win32)] --WiFi/WebSocket--> [ESP32 + Joystick] + -- GUI control panel (same process) mDNS: _insta360ctrl._tcp Binds all interfaces No auth required @@ -19,6 +23,7 @@ ESP32-based joystick controller for Insta360 Link webcam. Controls pan/tilt/zoom - **linkctl-daemon**: Mac-side C program that controls the camera directly via UVC extension units - **ESP32 firmware**: Reads joystick, discovers daemon via mDNS, sends JSON commands over WebSocket - **linkctl CLI**: Command-line client (pure POSIX C, zero dependencies), connects to daemon TCP socket on port 9001 +- **linkctl-panel**: Windows-side GUI control panel (pure Win32 C, zero dependencies) that replaces both the daemon and the CLI on Windows: controls the camera via DirectShow/kernel streaming and runs the same WebSocket + mDNS interface for the ESP32. No TCP/9001 control socket on Windows. ## Hardware @@ -83,11 +88,24 @@ make sudo cmake --install . --prefix /usr/local ``` +### Windows Control Panel + +```powershell +cd host\linkctl-panel +cmake -B build +cmake --build build --config Release + +.\build\Release\linkctl-panel.exe # Run the control panel +.\build\Release\linkctl-panel.exe --console # With a console for [camera]/[server] logs +``` + +Builds with MSVC or MinGW-w64 (CI cross-compiles with `x86_64-w64-mingw32-gcc` also works for syntax checking on Linux). No external libraries. + ## Protocol -Two interfaces: -- **WebSocket** (port 9000): JSON text frames for ESP32 firmware -- **TCP** (port 9001): Newline-terminated text commands for `linkctl` CLI +Two interfaces on macOS, one on Windows: +- **WebSocket** (port 9000): JSON text frames for ESP32 firmware (daemon and linkctl-panel) +- **TCP** (port 9001): Newline-terminated text commands for `linkctl` CLI (macOS daemon only) ### Commands (ESP32 → Daemon) @@ -133,6 +151,13 @@ Two interfaces: - `dns_sd.h` - mDNS advertisement (built into macOS) - `OpenSSL` - Required by libwebsockets headers (Homebrew) +**Windows panel (no external libraries — all built into Windows):** +- DirectShow (`strmiids`) - camera enumeration + `IAMCameraControl` zoom +- Kernel streaming (`IKsControl` via `KSP_NODE`) - XU pan/tilt/center (usbvideo.sys owns the device; raw USB transfers are not possible) +- SetupAPI + USB hub IOCTLs - reads the XU `guidExtensionCode` from USB descriptors at runtime +- Winsock (`ws2_32`) - hand-rolled RFC 6455 WebSocket server (own SHA-1/Base64, see `ws.c`) +- `dnsapi` (`DnsServiceRegister`) - mDNS advertisement, Windows 10 1809+ + ## File Structure ``` @@ -152,15 +177,24 @@ Two interfaces: │ ├── linkctl/ │ │ ├── CMakeLists.txt # CLI build config │ │ └── linkctl.c # CLI client (pure POSIX C, zero deps) -│ └── linkctl-daemon/ -│ ├── CMakeLists.txt # Daemon build config -│ ├── main.c # Daemon entry point and event loop -│ ├── camera.c/h # IOKit USB camera control -│ ├── server.c/h # WebSocket server + JSON command dispatch -│ ├── control.c/h # TCP control socket (for CLI) -│ ├── mdns.c/h # mDNS service advertisement -│ ├── install.sh # Installation script -│ └── service.sh # launchd service management helper +│ ├── linkctl-daemon/ +│ │ ├── CMakeLists.txt # Daemon build config +│ │ ├── main.c # Daemon entry point and event loop +│ │ ├── camera.c/h # IOKit USB camera control +│ │ ├── server.c/h # WebSocket server + JSON command dispatch +│ │ ├── control.c/h # TCP control socket (for CLI) +│ │ ├── mdns.c/h # mDNS service advertisement +│ │ ├── install.sh # Installation script +│ │ └── service.sh # launchd service management helper +│ └── linkctl-panel/ # Windows GUI control panel (pure Win32 C, zero deps) +│ ├── CMakeLists.txt # Panel build config (MSVC or MinGW) +│ ├── main.c # wWinMain: wires camera + server + mDNS + GUI +│ ├── gui.c/h # Win32 control panel (D-pad, sliders, status) +│ ├── camera.c/h # DirectShow/KS camera control + XU GUID discovery +│ ├── server.c/h # WebSocket server thread + JSON command dispatch +│ ├── ws.c/h # Minimal RFC 6455 codec (SHA-1, Base64, framing) +│ ├── json.c/h # Minimal JSON field extraction +│ └── mdns.c/h # DNS-SD advertisement (DnsServiceRegister) └── docs/ └── 2026-03-29-direct-uvc-control-design.md # Design document ``` @@ -185,3 +219,11 @@ Two interfaces: - **libwebsockets `lws_service()` blocks indefinitely** on macOS regardless of the timeout parameter when there are no pending events. Do not use it in any loop that needs to service other I/O. This is why the TCP control socket runs in its own thread. - The control thread uses a `pthread_mutex` around all `camera_*` calls to serialize access with the WebSocket thread. Any new code calling camera functions from the control path must hold `camera_mutex`. - After sending a `center` command, always follow with `stop` — the gimbal can drift after centering if not explicitly stopped. + +## Windows Panel Gotchas + +- `usbvideo.sys` owns the camera, so the macOS approach (raw class-specific USB control transfers) is impossible. XU controls must go through `IKsControl::KsProperty` with a `KSP_NODE` addressing the dev-specific topology node, and the property set GUID must be the XU's `guidExtensionCode` — not the unit ID. The GUID is read from the USB config descriptor via the parent hub (`IOCTL_USB_GET_DESCRIPTOR_FROM_NODE_CONNECTION`); don't hardcode it. +- `` needs `` included first (for `CTL_CODE`/`FILE_DEVICE_*`), at least under MinGW. +- `ksproxy.h`/`vidcap.h` are not reliably C-compatible across SDKs — `camera.c` declares the `IKsControl`/`IKsTopologyInfo` vtables locally instead. +- All `camera_*` functions take an internal `CRITICAL_SECTION`; COM is initialized as MTA, and every thread that touches the camera must call `camera_thread_attach()` first (the server thread does this on startup). +- Cross-compiling with `x86_64-w64-mingw32-gcc` on Linux is the fastest way to syntax-check; the portable parts (`ws.c`, `json.c`) also compile natively for unit testing with `-D_strnicmp=strncasecmp`. diff --git a/README.md b/README.md index af6cd26..1388044 100644 --- a/README.md +++ b/README.md @@ -6,20 +6,21 @@ The first-party control software for the [Insta360 Link](https://www.insta360.com/product/insta360-link) is (no offense) bloated garbage, even though the hardware is pretty solid. So, I took matters into my own hands. -This implementation controls the camera directly via USB/UVC extension units through `linkctl-daemon` a lightweight daemon — no Insta360 desktop app required. Interactions with the daemon can be driven by either: +This implementation controls the camera directly via USB/UVC extension units — no Insta360 desktop app required. On **macOS** the camera is driven by `linkctl-daemon`, a lightweight daemon, with either a CLI client (`linkctl`) or an ESP32-based joystick controller on top. On **Windows** a single native executable, `linkctl-panel`, provides a GUI control panel and speaks the same WebSocket protocol so the ESP32 controller works there too. -1. A CLI client `linkctl` -2. An ESP32-based joystick controller - -This repo contains the ESP32 firmware, Mac daemon and CLI source codes, and KiCad PCB design files for a custom handheld controller. +This repo contains the ESP32 firmware, the Mac daemon and CLI, the Windows control panel, and KiCad PCB design files for a custom handheld controller. ``` [Insta360 Link] ──USB/UVC──> [linkctl-daemon (Mac)] ──WiFi/WebSocket──> [ESP32 + Joystick] ──CLI Client──────> [linkctl Interface] + +[Insta360 Link] ──USB/UVC──> [linkctl-panel (Windows)] + ── GUI control panel (built in) + ──WiFi/WebSocket──> [ESP32 + Joystick] ``` ## Opportunities For Future Work: -- [ ] **Windows Support**: this is on my todo list +- [x] **Windows Support**: `linkctl-panel` — native GUI control panel (MVP, no viewfinder yet) - [ ] **Linux Support**: this is not on my todo list currently, but PRs are welcome :) - [ ] **Keyframe Implementation**: as I've been using this, the ability to have orientation presets has become the *one* feature I've missed from the first-party software. This is on my todo list for after Windows support @@ -82,6 +83,25 @@ sudo cmake --install . --prefix /usr/local Or run directly from the build directory: `./build/linkctl `. +### Windows: install `linkctl-panel` (instead of steps 1–2) + +On Windows, a single executable replaces both the daemon and the CLI: a native GUI control panel that also runs the WebSocket + mDNS interface for the ESP32 controller. No dependencies, no installer. Requires Windows 10 1809+ (for built-in mDNS). + +**Option A: Download the latest release** + +Download `linkctl-panel-*-windows-*.zip` from the [latest release](https://github.com/jfwoods/insta360link-joystick-controller/releases/latest), extract it, and run `linkctl-panel.exe`. + +**Option B: Build from source** (CMake + Visual Studio or MinGW-w64) + +```powershell +cd host\linkctl-panel +cmake -B build +cmake --build build --config Release +.\build\Release\linkctl-panel.exe +``` + +See [host/linkctl-panel/README.md](host/linkctl-panel/README.md) for details. + ### 3. Flash ESP32 firmware (if using joystick controller) Requires [PlatformIO](https://platformio.org/). @@ -107,9 +127,11 @@ connect ### 5. Use it -**CLI:** Run `linkctl status` to verify the daemon is running and the camera is connected, then use `linkctl jog` for interactive control. +**CLI (macOS):** Run `linkctl status` to verify the daemon is running and the camera is connected, then use `linkctl jog` for interactive control. -**Joystick:** Move the joystick to pan and tilt the camera. The ESP32 reads the analog input, converts it to direction and speed, and sends commands to the daemon over WebSocket. The ESP32 discovers the daemon automatically via mDNS — no IP configuration needed. +**Control panel (Windows):** Launch `linkctl-panel.exe` — hold the D-pad buttons (or arrow keys) to pan/tilt, drag the zoom slider or press `+`/`-`, press `C` or the button to center the gimbal. + +**Joystick:** Move the joystick to pan and tilt the camera. The ESP32 reads the analog input, converts it to direction and speed, and sends commands to the host over WebSocket. The ESP32 discovers the daemon (or Windows panel) automatically via mDNS — no IP configuration needed. ## Dependencies @@ -128,6 +150,10 @@ connect No external dependencies — pure POSIX C. +### Windows control panel (`linkctl-panel`) + +No external dependencies — pure Win32 C (DirectShow/kernel streaming for camera control, Winsock for the WebSocket server, built-in Windows DNS-SD for mDNS). + ### ESP32 Firmware | Dependency | Source | Purpose | diff --git a/host/linkctl-panel/CMakeLists.txt b/host/linkctl-panel/CMakeLists.txt new file mode 100644 index 0000000..2aeca7e --- /dev/null +++ b/host/linkctl-panel/CMakeLists.txt @@ -0,0 +1,47 @@ +cmake_minimum_required(VERSION 3.16) +project(linkctl-panel C) + +if(NOT WIN32) + message(FATAL_ERROR "linkctl-panel is Windows-only. On macOS use host/linkctl-daemon + host/linkctl.") +endif() + +set(CMAKE_C_STANDARD 11) + +add_executable(linkctl-panel WIN32 + main.c + gui.c + camera.c + server.c + ws.c + json.c + mdns.c +) + +# DnsServiceRegister and friends need the Windows 10 1809+ API surface +target_compile_definitions(linkctl-panel PRIVATE + _WIN32_WINNT=0x0A00 + UNICODE + _UNICODE + _CRT_SECURE_NO_WARNINGS +) + +if(MSVC) + target_compile_options(linkctl-panel PRIVATE /W3) + target_link_options(linkctl-panel PRIVATE /ENTRY:wWinMainCRTStartup) +else() + target_compile_options(linkctl-panel PRIVATE -Wall -Wextra -municode) + target_link_options(linkctl-panel PRIVATE -municode -static) +endif() + +target_link_libraries(linkctl-panel PRIVATE + ole32 # COM + oleaut32 # VARIANT/BSTR for the DirectShow property bag + strmiids # DirectShow CLSIDs/IIDs + uuid + setupapi # USB hub enumeration (XU GUID discovery) + ws2_32 # WebSocket server + dnsapi # DNS-SD (mDNS) advertisement + comctl32 # Trackbars, button subclassing + user32 + gdi32 +) diff --git a/host/linkctl-panel/README.md b/host/linkctl-panel/README.md new file mode 100644 index 0000000..44d8a47 --- /dev/null +++ b/host/linkctl-panel/README.md @@ -0,0 +1,65 @@ +# linkctl-panel + +Native Windows control panel for the Insta360 Link. A single small `.exe` +with zero dependencies — no Insta360 desktop app, no installer, no +services. It is the Windows equivalent of `linkctl-daemon` + `linkctl` +combined: a GUI for local control, plus the same WebSocket + mDNS +interface so the ESP32 joystick controller works against it unchanged. + +``` +[Insta360 Link] --USB/UVC--> [linkctl-panel.exe] --WiFi/WebSocket--> [ESP32 + Joystick] + GUI control panel mDNS: _insta360ctrl._tcp +``` + +## Features + +- **Pan/tilt**: hold-to-move D-pad buttons or arrow keys, with speed sliders +- **Zoom**: slider or `+`/`-` keys (1.0x–4.0x) +- **Center gimbal**: button or `C` key (centers, stops, resets zoom — same + sequence as the `linkctl center` CLI command) +- **Stop**: button, `Space`, or `Esc` +- **ESP32 support**: WebSocket server on port 9000 with the same JSON + protocol as the Mac daemon, advertised via mDNS (Windows' built-in + DNS-SD — no Bonjour install required, Windows 10 1809+) +- Auto-reconnects when the camera is unplugged/replugged + +No viewfinder — this is a controller, not a capture app. Use the camera in +any other application (Teams, OBS, ...) while the panel drives it. + +## How it works + +Windows' `usbvideo.sys` owns the camera, so raw USB control transfers (the +macOS approach) aren't possible. Instead: + +- **Pan/tilt/center** go through the camera's UVC extension unit via kernel + streaming (`IKsControl::KsProperty`). Windows addresses extension units + by GUID rather than unit ID, so at startup the panel reads the camera's + USB configuration descriptor through its parent hub and extracts the + `guidExtensionCode` for unit `0x09` — no hardcoded vendor GUID. +- **Zoom** uses the standard UVC Camera Terminal control via DirectShow's + `IAMCameraControl`. + +## Build + +Requires CMake and either Visual Studio (MSVC) or MinGW-w64. No libraries +to install. + +```powershell +cd host\linkctl-panel +cmake -B build +cmake --build build --config Release +.\build\Release\linkctl-panel.exe +``` + +Run with `--console` to attach a console and see camera/server logs: + +```powershell +.\linkctl-panel.exe --console +``` + +## Protocol + +Identical to `linkctl-daemon` — see the repository root README. ESP32 +clients discover the panel via mDNS (`_insta360ctrl._tcp`) and send JSON +commands (`pan_tilt`, `zoom`, `stop`, `center`, `status`) over WebSocket +on port 9000. diff --git a/host/linkctl-panel/camera.c b/host/linkctl-panel/camera.c new file mode 100644 index 0000000..577641e --- /dev/null +++ b/host/linkctl-panel/camera.c @@ -0,0 +1,539 @@ +// Windows camera control for the Insta360 Link. +// +// Pan/tilt/center go through the camera's UVC extension unit. usbvideo.sys +// owns the device, so raw USB control transfers are not possible; instead +// the XU is reached through kernel streaming: IKsControl::KsProperty with +// the XU's guidExtensionCode as the property set and the UVC selector as +// the property ID. The GUID is not hardcoded — it is read at runtime from +// the device's VideoControl interface descriptor via the parent USB hub. +// +// Zoom uses the standard UVC Camera Terminal control, exposed by Windows +// as IAMCameraControl / CameraControl_Zoom. + +#define COBJMACROS +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "camera.h" + +// --- Local declarations for KS interfaces not exposed in C-friendly SDK headers --- + +// IKsControl (ksproxy.h is C++-only in some SDKs) +typedef struct IKsControlC IKsControlC; +typedef struct { + HRESULT (STDMETHODCALLTYPE *QueryInterface)(IKsControlC *, REFIID, void **); + ULONG (STDMETHODCALLTYPE *AddRef)(IKsControlC *); + ULONG (STDMETHODCALLTYPE *Release)(IKsControlC *); + HRESULT (STDMETHODCALLTYPE *KsProperty)(IKsControlC *, PKSPROPERTY, ULONG, + void *, ULONG, ULONG *); + HRESULT (STDMETHODCALLTYPE *KsMethod)(IKsControlC *, PKSMETHOD, ULONG, + void *, ULONG, ULONG *); + HRESULT (STDMETHODCALLTYPE *KsEvent)(IKsControlC *, PKSEVENT, ULONG, + void *, ULONG, ULONG *); +} IKsControlCVtbl; +struct IKsControlC { const IKsControlCVtbl *lpVtbl; }; + +static const GUID IID_IKsControl_local = + {0x28F54685, 0x06FD, 0x11D2, {0xB2, 0x7A, 0x00, 0xA0, 0xC9, 0x22, 0x31, 0x96}}; + +// IKsTopologyInfo (vidcap.h is not always usable from C) +typedef struct IKsTopologyInfoC IKsTopologyInfoC; +typedef struct { + HRESULT (STDMETHODCALLTYPE *QueryInterface)(IKsTopologyInfoC *, REFIID, void **); + ULONG (STDMETHODCALLTYPE *AddRef)(IKsTopologyInfoC *); + ULONG (STDMETHODCALLTYPE *Release)(IKsTopologyInfoC *); + HRESULT (STDMETHODCALLTYPE *get_NumCategories)(IKsTopologyInfoC *, DWORD *); + HRESULT (STDMETHODCALLTYPE *get_Category)(IKsTopologyInfoC *, DWORD, GUID *); + HRESULT (STDMETHODCALLTYPE *get_NumConnections)(IKsTopologyInfoC *, DWORD *); + HRESULT (STDMETHODCALLTYPE *get_ConnectionInfo)(IKsTopologyInfoC *, DWORD, void *); + HRESULT (STDMETHODCALLTYPE *get_NodeName)(IKsTopologyInfoC *, DWORD, WCHAR *, + DWORD, DWORD *); + HRESULT (STDMETHODCALLTYPE *get_NumNodes)(IKsTopologyInfoC *, DWORD *); + HRESULT (STDMETHODCALLTYPE *get_NodeType)(IKsTopologyInfoC *, DWORD, GUID *); + HRESULT (STDMETHODCALLTYPE *CreateNodeInstance)(IKsTopologyInfoC *, DWORD, + REFIID, void **); +} IKsTopologyInfoCVtbl; +struct IKsTopologyInfoC { const IKsTopologyInfoCVtbl *lpVtbl; }; + +static const GUID IID_IKsTopologyInfo_local = + {0x720D4AC0, 0x7533, 0x11D0, {0xA5, 0xD6, 0x28, 0xDB, 0x04, 0xC1, 0x00, 0x00}}; + +static const GUID KSNODETYPE_DEV_SPECIFIC_local = + {0x941C7AC0, 0xC559, 0x11D0, {0x8A, 0x2B, 0x00, 0xA0, 0xC9, 0x25, 0x5A, 0xC1}}; + +static const GUID GUID_DEVINTERFACE_USB_HUB_local = + {0xF18A0E88, 0xC30C, 0x11D0, {0x88, 0x15, 0x00, 0xA0, 0xC9, 0x06, 0xBE, 0xD8}}; + +// --- State --- + +static CRITICAL_SECTION cam_lock; +static bool subsystem_ready = false; + +static IBaseFilter *filter = NULL; +static IAMCameraControl *cam_ctrl = NULL; +static IKsControlC *ks_ctrl = NULL; + +static GUID xu_guid; +static bool xu_guid_valid = false; +static ULONG xu_node_id = 0; +static bool xu_node_valid = false; + +// IAMCameraControl zoom range reported by the driver, used to map the +// 100-400 protocol range onto whatever the driver exposes. +static long zoom_dev_min = ZOOM_MIN; +static long zoom_dev_max = ZOOM_MAX; +static bool zoom_supported = false; + +// --- USB descriptor walk: find the XU's guidExtensionCode --- +// +// Enumerate USB hubs, find the port the camera is attached to (by VID/PID), +// pull its configuration descriptor through the hub, and locate the +// VC_EXTENSION_UNIT descriptor whose bUnitID matches XU_UNIT_ID. The +// guidExtensionCode bytes are stored in the same order as a Windows GUID +// in memory, so a straight copy is correct. + +#define USB_DESC_TYPE_INTERFACE 0x04 +#define USB_DESC_TYPE_CS_INTERFACE 0x24 +#define UVC_CLASS_VIDEO 0x0E +#define UVC_SUBCLASS_VIDEOCONTROL 0x01 +#define UVC_VC_EXTENSION_UNIT 0x06 + +static bool parse_config_for_xu_guid(const uint8_t *desc, size_t len, + uint8_t unit_id, GUID *out) { + bool in_videocontrol = false; + size_t off = 0; + + while (off + 2 <= len) { + uint8_t dlen = desc[off]; + uint8_t dtype = desc[off + 1]; + if (dlen < 2 || off + dlen > len) break; + + if (dtype == USB_DESC_TYPE_INTERFACE && dlen >= 9) { + in_videocontrol = (desc[off + 5] == UVC_CLASS_VIDEO && + desc[off + 6] == UVC_SUBCLASS_VIDEOCONTROL); + } else if (in_videocontrol && dtype == USB_DESC_TYPE_CS_INTERFACE && + dlen >= 22 && desc[off + 2] == UVC_VC_EXTENSION_UNIT) { + // bLength, bDescriptorType, bDescriptorSubtype, bUnitID, guid[16], ... + if (desc[off + 3] == unit_id) { + memcpy(out, desc + off + 4, sizeof(GUID)); + return true; + } + } + off += dlen; + } + return false; +} + +static bool hub_port_get_xu_guid(HANDLE hub, ULONG port, GUID *out) { + // Is the matching camera on this port? + USB_NODE_CONNECTION_INFORMATION_EX info; + DWORD bytes = 0; + memset(&info, 0, sizeof(info)); + info.ConnectionIndex = port; + if (!DeviceIoControl(hub, IOCTL_USB_GET_NODE_CONNECTION_INFORMATION_EX, + &info, sizeof(info), &info, sizeof(info), &bytes, NULL)) + return false; + if (info.ConnectionStatus != DeviceConnected) return false; + if (info.DeviceDescriptor.idVendor != INSTA360_VID || + info.DeviceDescriptor.idProduct != INSTA360_PID) + return false; + + // Fetch the full configuration descriptor through the hub (UVC config + // descriptors with multiple streaming formats can run several KB) + static uint8_t buf[sizeof(USB_DESCRIPTOR_REQUEST) + 12288]; + USB_DESCRIPTOR_REQUEST *req = (USB_DESCRIPTOR_REQUEST *)buf; + memset(buf, 0, sizeof(buf)); + req->ConnectionIndex = port; + req->SetupPacket.bmRequest = 0x80; // device-to-host, standard, device + req->SetupPacket.bRequest = USB_REQUEST_GET_DESCRIPTOR; + req->SetupPacket.wValue = (USB_CONFIGURATION_DESCRIPTOR_TYPE << 8) | 0; + req->SetupPacket.wIndex = 0; + req->SetupPacket.wLength = (USHORT)(sizeof(buf) - sizeof(USB_DESCRIPTOR_REQUEST)); + + if (!DeviceIoControl(hub, IOCTL_USB_GET_DESCRIPTOR_FROM_NODE_CONNECTION, + buf, sizeof(buf), buf, sizeof(buf), &bytes, NULL)) + return false; + if (bytes <= sizeof(USB_DESCRIPTOR_REQUEST)) return false; + + return parse_config_for_xu_guid(buf + sizeof(USB_DESCRIPTOR_REQUEST), + bytes - sizeof(USB_DESCRIPTOR_REQUEST), + XU_UNIT_ID, out); +} + +static bool find_xu_guid(GUID *out) { + HDEVINFO devs = SetupDiGetClassDevsW(&GUID_DEVINTERFACE_USB_HUB_local, NULL, + NULL, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE); + if (devs == INVALID_HANDLE_VALUE) return false; + + bool found = false; + SP_DEVICE_INTERFACE_DATA ifdata; + ifdata.cbSize = sizeof(ifdata); + + for (DWORD i = 0; + !found && SetupDiEnumDeviceInterfaces(devs, NULL, + &GUID_DEVINTERFACE_USB_HUB_local, i, &ifdata); + i++) { + DWORD needed = 0; + SetupDiGetDeviceInterfaceDetailW(devs, &ifdata, NULL, 0, &needed, NULL); + if (needed == 0) continue; + + SP_DEVICE_INTERFACE_DETAIL_DATA_W *detail = malloc(needed); + if (!detail) continue; + detail->cbSize = sizeof(*detail); + if (SetupDiGetDeviceInterfaceDetailW(devs, &ifdata, detail, needed, + NULL, NULL)) { + HANDLE hub = CreateFileW(detail->DevicePath, GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + NULL, OPEN_EXISTING, 0, NULL); + if (hub != INVALID_HANDLE_VALUE) { + USB_NODE_INFORMATION node; + DWORD bytes = 0; + memset(&node, 0, sizeof(node)); + if (DeviceIoControl(hub, IOCTL_USB_GET_NODE_INFORMATION, + &node, sizeof(node), &node, sizeof(node), + &bytes, NULL)) { + ULONG ports = node.u.HubInformation.HubDescriptor.bNumberOfPorts; + for (ULONG p = 1; p <= ports && !found; p++) { + found = hub_port_get_xu_guid(hub, p, out); + } + } + CloseHandle(hub); + } + } + free(detail); + } + + SetupDiDestroyDeviceInfoList(devs); + return found; +} + +// --- DirectShow: find the camera's filter by VID/PID in its device path --- + +static IBaseFilter *find_camera_filter(void) { + ICreateDevEnum *devenum = NULL; + IEnumMoniker *enum_mon = NULL; + IBaseFilter *result = NULL; + + HRESULT hr = CoCreateInstance(&CLSID_SystemDeviceEnum, NULL, CLSCTX_INPROC_SERVER, + &IID_ICreateDevEnum, (void **)&devenum); + if (FAILED(hr)) return NULL; + + hr = ICreateDevEnum_CreateClassEnumerator(devenum, + &CLSID_VideoInputDeviceCategory, &enum_mon, 0); + ICreateDevEnum_Release(devenum); + if (hr != S_OK) return NULL; // S_FALSE = empty category + + IMoniker *mon = NULL; + while (!result && IEnumMoniker_Next(enum_mon, 1, &mon, NULL) == S_OK) { + IPropertyBag *bag = NULL; + if (SUCCEEDED(IMoniker_BindToStorage(mon, NULL, NULL, &IID_IPropertyBag, + (void **)&bag))) { + VARIANT var; + VariantInit(&var); + if (SUCCEEDED(IPropertyBag_Read(bag, L"DevicePath", &var, NULL)) && + var.vt == VT_BSTR && var.bstrVal) { + // Device paths look like \\?\usb#vid_2e1a&pid_4c01#... + WCHAR path[512]; + wcsncpy(path, var.bstrVal, 511); + path[511] = L'\0'; + _wcslwr(path); + if (wcsstr(path, L"vid_2e1a") && wcsstr(path, L"pid_4c01")) { + IMoniker_BindToObject(mon, NULL, NULL, &IID_IBaseFilter, + (void **)&result); + } + } + VariantClear(&var); + IPropertyBag_Release(bag); + } + IMoniker_Release(mon); + } + + IEnumMoniker_Release(enum_mon); + return result; +} + +// --- XU access via kernel streaming --- + +static HRESULT xu_ks_property(ULONG node, ULONG selector, ULONG flags, + void *data, ULONG len, ULONG *returned) { + KSP_NODE prop; + memset(&prop, 0, sizeof(prop)); + prop.Property.Set = xu_guid; + prop.Property.Id = selector; + prop.Property.Flags = flags | KSPROPERTY_TYPE_TOPOLOGY; + prop.NodeId = node; + + return ks_ctrl->lpVtbl->KsProperty(ks_ctrl, &prop.Property, sizeof(prop), + data, len, returned); +} + +// Locate the topology node that answers for the XU property set. Prefers +// the dev-specific nodes reported by IKsTopologyInfo; falls back to a +// blind probe of low node IDs if topology info is unavailable. +static bool find_xu_node(void) { + ULONG candidates[32]; + DWORD num_candidates = 0; + + IKsTopologyInfoC *topo = NULL; + if (SUCCEEDED(IBaseFilter_QueryInterface(filter, &IID_IKsTopologyInfo_local, + (void **)&topo))) { + DWORD num_nodes = 0; + if (SUCCEEDED(topo->lpVtbl->get_NumNodes(topo, &num_nodes))) { + for (DWORD i = 0; i < num_nodes && num_candidates < 32; i++) { + GUID type; + if (SUCCEEDED(topo->lpVtbl->get_NodeType(topo, i, &type)) && + IsEqualGUID(&type, &KSNODETYPE_DEV_SPECIFIC_local)) { + candidates[num_candidates++] = i; + } + } + } + topo->lpVtbl->Release(topo); + } + + if (num_candidates == 0) { + for (ULONG i = 0; i < 32; i++) candidates[i] = i; + num_candidates = 32; + } + + for (DWORD i = 0; i < num_candidates; i++) { + ULONG support = 0, returned = 0; + HRESULT hr = xu_ks_property(candidates[i], SEL_PAN_TILT_RELATIVE, + KSPROPERTY_TYPE_BASICSUPPORT, + &support, sizeof(support), &returned); + // A "buffer too small" reply still means this node answers for the + // XU property set (some drivers return a full KSPROPERTY_DESCRIPTION) + if (hr == HRESULT_FROM_WIN32(ERROR_MORE_DATA) || + hr == HRESULT_FROM_WIN32(ERROR_INSUFFICIENT_BUFFER)) { + hr = S_OK; + } + if (SUCCEEDED(hr)) { + xu_node_id = candidates[i]; + return true; + } + } + return false; +} + +static int xu_set_cur(uint8_t selector, void *data, ULONG len) { + if (!ks_ctrl || !xu_guid_valid || !xu_node_valid) return -1; + + ULONG returned = 0; + HRESULT hr = xu_ks_property(xu_node_id, selector, KSPROPERTY_TYPE_SET, + data, len, &returned); + if (FAILED(hr)) { + fprintf(stderr, "[camera] XU SET failed (sel=0x%02x): 0x%08lx\n", + selector, (unsigned long)hr); + return -1; + } + return 0; +} + +// --- Zoom mapping between the 100-400 protocol range and the driver range --- + +static long zoom_to_dev(uint16_t v) { + if (zoom_dev_max <= zoom_dev_min) return zoom_dev_min; + return zoom_dev_min + + ((long)(v - ZOOM_MIN) * (zoom_dev_max - zoom_dev_min) + + (ZOOM_MAX - ZOOM_MIN) / 2) / (ZOOM_MAX - ZOOM_MIN); +} + +static int zoom_from_dev(long dev) { + if (zoom_dev_max <= zoom_dev_min) return ZOOM_MIN; + return ZOOM_MIN + + (int)(((dev - zoom_dev_min) * (ZOOM_MAX - ZOOM_MIN) + + (zoom_dev_max - zoom_dev_min) / 2) / + (zoom_dev_max - zoom_dev_min)); +} + +// --- Internal (lock already held) --- + +static void close_locked(void) { + if (cam_ctrl) { + IAMCameraControl_Release(cam_ctrl); + cam_ctrl = NULL; + } + if (ks_ctrl) { + ks_ctrl->lpVtbl->Release(ks_ctrl); + ks_ctrl = NULL; + } + if (filter) { + IBaseFilter_Release(filter); + filter = NULL; + } + xu_node_valid = false; + zoom_supported = false; +} + +static int open_locked(void) { + if (filter) return 0; // Already open + + filter = find_camera_filter(); + if (!filter) { + return -1; // Not found — caller can retry + } + + // Zoom via the standard camera control interface + if (SUCCEEDED(IBaseFilter_QueryInterface(filter, &IID_IAMCameraControl, + (void **)&cam_ctrl))) { + long step, def, caps; + if (SUCCEEDED(IAMCameraControl_GetRange(cam_ctrl, CameraControl_Zoom, + &zoom_dev_min, &zoom_dev_max, + &step, &def, &caps))) { + zoom_supported = true; + } else { + fprintf(stderr, "[camera] zoom range query failed\n"); + } + } + + // Pan/tilt via the extension unit + if (FAILED(IBaseFilter_QueryInterface(filter, &IID_IKsControl_local, + (void **)&ks_ctrl))) { + ks_ctrl = NULL; + fprintf(stderr, "[camera] IKsControl unavailable — pan/tilt disabled\n"); + } + + if (!xu_guid_valid) { + xu_guid_valid = find_xu_guid(&xu_guid); + if (!xu_guid_valid) { + fprintf(stderr, "[camera] XU GUID not found in USB descriptors\n"); + } + } + + if (ks_ctrl && xu_guid_valid) { + xu_node_valid = find_xu_node(); + if (!xu_node_valid) { + fprintf(stderr, "[camera] XU topology node not found\n"); + } + } + + if (!zoom_supported && !xu_node_valid) { + // Found the device but can't control anything — treat as not open + close_locked(); + return -1; + } + + printf("[camera] Insta360 Link found (zoom=%s, pan/tilt=%s)\n", + zoom_supported ? "yes" : "no", xu_node_valid ? "yes" : "no"); + return 0; +} + +// --- Public API --- + +int camera_init(void) { + if (!subsystem_ready) { + InitializeCriticalSection(&cam_lock); + subsystem_ready = true; + } + // COM is initialized per-thread by camera_thread_attach() + camera_thread_attach(); + return 0; +} + +void camera_thread_attach(void) { + // Interfaces are shared across the GUI and server threads and access is + // serialized by cam_lock, so the multithreaded apartment is required. + CoInitializeEx(NULL, COINIT_MULTITHREADED); +} + +int camera_open(void) { + EnterCriticalSection(&cam_lock); + int rc = open_locked(); + LeaveCriticalSection(&cam_lock); + return rc; +} + +void camera_close(void) { + EnterCriticalSection(&cam_lock); + close_locked(); + LeaveCriticalSection(&cam_lock); +} + +void camera_destroy(void) { + camera_close(); +} + +bool camera_is_connected(void) { + EnterCriticalSection(&cam_lock); + bool connected = (filter != NULL); + LeaveCriticalSection(&cam_lock); + return connected; +} + +int camera_pan_tilt(uint8_t pan_dir, uint8_t pan_speed, + uint8_t tilt_dir, uint8_t tilt_speed) { + uint8_t data[4] = { pan_dir, pan_speed, tilt_dir, tilt_speed }; + EnterCriticalSection(&cam_lock); + int rc = xu_set_cur(SEL_PAN_TILT_RELATIVE, data, sizeof(data)); + if (rc != 0) close_locked(); + LeaveCriticalSection(&cam_lock); + return rc; +} + +int camera_stop(void) { + return camera_pan_tilt(DIR_STOP, 1, DIR_STOP, 1); +} + +int camera_center(void) { + uint8_t data[8] = {0}; + EnterCriticalSection(&cam_lock); + int rc = xu_set_cur(SEL_GIMBAL_CENTER, data, sizeof(data)); + if (rc != 0) close_locked(); + LeaveCriticalSection(&cam_lock); + return rc; +} + +int camera_zoom(uint16_t value) { + if (value < ZOOM_MIN || value > ZOOM_MAX) return -1; + + EnterCriticalSection(&cam_lock); + int rc = -1; + if (cam_ctrl && zoom_supported) { + HRESULT hr = IAMCameraControl_Set(cam_ctrl, CameraControl_Zoom, + zoom_to_dev(value), + CameraControl_Flags_Manual); + if (SUCCEEDED(hr)) { + rc = 0; + } else { + fprintf(stderr, "[camera] zoom set failed: 0x%08lx\n", + (unsigned long)hr); + close_locked(); + } + } + LeaveCriticalSection(&cam_lock); + return rc; +} + +int camera_get_zoom(void) { + EnterCriticalSection(&cam_lock); + int result = -1; + if (cam_ctrl && zoom_supported) { + long dev = 0, flags = 0; + if (SUCCEEDED(IAMCameraControl_Get(cam_ctrl, CameraControl_Zoom, + &dev, &flags))) { + result = zoom_from_dev(dev); + } + } + LeaveCriticalSection(&cam_lock); + return result; +} + +camera_status_t camera_get_status(void) { + camera_status_t status; + status.connected = camera_is_connected(); + status.zoom = 0; + + if (status.connected) { + int z = camera_get_zoom(); + if (z >= 0) status.zoom = (uint16_t)z; + } + + return status; +} diff --git a/host/linkctl-panel/camera.h b/host/linkctl-panel/camera.h new file mode 100644 index 0000000..73cfcbd --- /dev/null +++ b/host/linkctl-panel/camera.h @@ -0,0 +1,80 @@ +#ifndef CAMERA_H +#define CAMERA_H + +#include +#include + +// Insta360 Link USB identifiers +#define INSTA360_VID 0x2e1a +#define INSTA360_PID 0x4c01 + +// UVC Extension Unit for Insta360 vendor controls. +// On Windows the XU is addressed by its guidExtensionCode (read from the +// USB descriptors at runtime); the unit ID is only used to locate that +// GUID in the VideoControl interface descriptor. +#define XU_UNIT_ID 0x09 + +// UVC selectors +#define SEL_PAN_TILT_RELATIVE 0x16 // 22 +#define SEL_GIMBAL_CENTER 0x1A // 26 +#define SEL_ZOOM_ABSOLUTE 0x0B // 11 + +// Speed limits +#define PAN_MAX_SPEED 30 +#define TILT_MAX_SPEED 20 +#define ZOOM_MIN 100 +#define ZOOM_MAX 400 + +// Direction encoding (matches UVC wire format) +#define DIR_POSITIVE 1 +#define DIR_NEGATIVE 255 +#define DIR_STOP 0 + +typedef struct { + bool connected; + uint16_t zoom; // Current zoom level (100-400) +} camera_status_t; + +// Initialize the camera subsystem (COM, locking). Returns 0 on success. +// All camera_* functions are thread-safe: an internal critical section +// serializes access between the GUI thread and the WebSocket server thread. +int camera_init(void); + +// Initialize COM (MTA) on the calling thread. Must be called once by every +// thread that uses camera_* functions; camera_init() covers its own thread. +void camera_thread_attach(void); + +// Attempt to find the Insta360 Link via DirectShow. Returns 0 on success. +int camera_open(void); + +// Release all COM interfaces. +void camera_close(void); + +// Shut down the camera subsystem. +void camera_destroy(void); + +// Returns true if the camera is currently open. +bool camera_is_connected(void); + +// Send a relative pan/tilt command. +// pan_dir/tilt_dir: DIR_POSITIVE, DIR_NEGATIVE, or DIR_STOP +// pan_speed: 0-30, tilt_speed: 0-20 +int camera_pan_tilt(uint8_t pan_dir, uint8_t pan_speed, + uint8_t tilt_dir, uint8_t tilt_speed); + +// Stop all gimbal movement. +int camera_stop(void); + +// Center the gimbal. +int camera_center(void); + +// Set absolute zoom level (100-400). +int camera_zoom(uint16_t value); + +// Get current zoom level. Returns zoom value, or -1 on error. +int camera_get_zoom(void); + +// Get overall camera status. +camera_status_t camera_get_status(void); + +#endif diff --git a/host/linkctl-panel/gui.c b/host/linkctl-panel/gui.c new file mode 100644 index 0000000..62ef93e --- /dev/null +++ b/host/linkctl-panel/gui.c @@ -0,0 +1,400 @@ +// Win32 control panel: hold-to-move D-pad (mouse or arrow keys), speed +// sliders, zoom slider, center/stop, and a status line. No viewfinder — +// this is a controller, not a capture app. + +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include + +#include "gui.h" +#include "camera.h" +#include "server.h" + +#define IDC_BTN_UP 101 +#define IDC_BTN_DOWN 102 +#define IDC_BTN_LEFT 103 +#define IDC_BTN_RIGHT 104 +#define IDC_BTN_STOP 105 +#define IDC_BTN_CENTER 106 +#define IDC_SLD_PAN 110 +#define IDC_SLD_TILT 111 +#define IDC_SLD_ZOOM 112 +#define IDC_LBL_STATUS 120 +#define IDC_LBL_PAN 121 +#define IDC_LBL_TILT 122 +#define IDC_LBL_ZOOM 123 +#define IDC_LBL_CLIENTS 124 + +#define TIMER_STATUS 1 +#define STATUS_INTERVAL_MS 1000 +#define RECONNECT_INTERVAL_MS 5000 + +#define DEFAULT_PAN_SPEED 15 +#define DEFAULT_TILT_SPEED 10 +#define ZOOM_KEY_STEP 10 + +static HWND main_wnd; +static HWND sld_pan, sld_tilt, sld_zoom; +static HWND lbl_status, lbl_pan, lbl_tilt, lbl_zoom, lbl_clients; +static HFONT gui_font, dpad_font; + +// Desired motion, driven by D-pad mouse state and arrow-key state. +// Recomputed and resent whenever any input changes. +static struct { + bool up, down, left, right; +} motion; +static bool moving = false; +static int last_zoom_sent = -1; +static ULONGLONG last_reconnect = 0; + +// --- Camera command helpers --- + +static void send_motion(void) { + uint8_t pan_dir = DIR_STOP, tilt_dir = DIR_STOP; + uint8_t pan_speed = 0, tilt_speed = 0; + + if (motion.left != motion.right) { + pan_dir = motion.right ? DIR_POSITIVE : DIR_NEGATIVE; + pan_speed = (uint8_t)SendMessageW(sld_pan, TBM_GETPOS, 0, 0); + } + if (motion.up != motion.down) { + tilt_dir = motion.up ? DIR_POSITIVE : DIR_NEGATIVE; + tilt_speed = (uint8_t)SendMessageW(sld_tilt, TBM_GETPOS, 0, 0); + } + + if (pan_speed == 0 && tilt_speed == 0) { + if (moving) { + camera_stop(); + moving = false; + } + } else { + camera_pan_tilt(pan_dir, pan_speed, tilt_dir, tilt_speed); + moving = true; + } +} + +static void stop_all(void) { + motion.up = motion.down = motion.left = motion.right = false; + camera_stop(); + moving = false; +} + +static void set_zoom(int value) { + if (value < ZOOM_MIN) value = ZOOM_MIN; + if (value > ZOOM_MAX) value = ZOOM_MAX; + if (value == last_zoom_sent) return; + + if (camera_zoom((uint16_t)value) == 0) { + last_zoom_sent = value; + } + SendMessageW(sld_zoom, TBM_SETPOS, TRUE, value); + + WCHAR buf[32]; + swprintf(buf, 32, L"%d.%dx", value / 100, (value % 100) / 10); + SetWindowTextW(lbl_zoom, buf); +} + +static void do_center(void) { + // Center, then stop, then reset zoom — same sequence as the linkctl + // CLI's `center` command (the gimbal can drift after centering if not + // explicitly stopped). + camera_center(); + camera_stop(); + moving = false; + last_zoom_sent = -1; + set_zoom(ZOOM_MIN); +} + +// --- Status line --- + +static void update_status(void) { + camera_status_t st = camera_get_status(); + + WCHAR buf[128]; + if (st.connected) { + swprintf(buf, 128, L"Camera: connected \x2014 zoom %d.%dx", + st.zoom / 100, (st.zoom % 100) / 10); + } else { + swprintf(buf, 128, L"Camera: not found \x2014 plug in the Insta360 Link"); + } + SetWindowTextW(lbl_status, buf); + + swprintf(buf, 128, L"ESP32 clients: %d \x00b7 ws://0.0.0.0:%d", + server_client_count(), DEFAULT_PORT); + SetWindowTextW(lbl_clients, buf); + + // Keep the zoom slider in sync when zoom was changed elsewhere + // (e.g. by the ESP32), unless the user is dragging it. + if (st.connected && st.zoom >= ZOOM_MIN && GetCapture() != sld_zoom) { + int pos = (int)SendMessageW(sld_zoom, TBM_GETPOS, 0, 0); + if (pos != st.zoom) { + SendMessageW(sld_zoom, TBM_SETPOS, TRUE, st.zoom); + last_zoom_sent = st.zoom; + WCHAR z[32]; + swprintf(z, 32, L"%d.%dx", st.zoom / 100, (st.zoom % 100) / 10); + SetWindowTextW(lbl_zoom, z); + } + } + + if (!st.connected) { + ULONGLONG now = GetTickCount64(); + if (now - last_reconnect >= RECONNECT_INTERVAL_MS) { + last_reconnect = now; + if (camera_open() == 0) { + update_status(); + } + } + } +} + +static void update_speed_labels(void) { + WCHAR buf[16]; + swprintf(buf, 16, L"%d", (int)SendMessageW(sld_pan, TBM_GETPOS, 0, 0)); + SetWindowTextW(lbl_pan, buf); + swprintf(buf, 16, L"%d", (int)SendMessageW(sld_tilt, TBM_GETPOS, 0, 0)); + SetWindowTextW(lbl_tilt, buf); +} + +// --- D-pad: hold-to-move buttons --- + +static bool *motion_flag_for_id(UINT_PTR id) { + switch (id) { + case IDC_BTN_UP: return &motion.up; + case IDC_BTN_DOWN: return &motion.down; + case IDC_BTN_LEFT: return &motion.left; + case IDC_BTN_RIGHT: return &motion.right; + default: return NULL; + } +} + +static LRESULT CALLBACK dpad_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp, + UINT_PTR id, DWORD_PTR ref) { + (void)ref; + bool *flag = motion_flag_for_id(id); + + switch (msg) { + case WM_LBUTTONDOWN: + if (flag) { + *flag = true; + send_motion(); + } + break; + case WM_LBUTTONUP: + case WM_CAPTURECHANGED: + if (flag && *flag) { + *flag = false; + send_motion(); + } + break; + case WM_KEYDOWN: + case WM_KEYUP: + case WM_CHAR: + // Keys are handled app-wide in the message loop; stop the button + // from also reacting to space/enter. + return 0; + } + return DefSubclassProc(hwnd, msg, wp, lp); +} + +// --- Keyboard: arrows pan/tilt, +/- zoom, C center, Esc/Space stop --- + +static bool handle_key(MSG *msg) { + bool down = (msg->message == WM_KEYDOWN); + if (!down && msg->message != WM_KEYUP) return false; + bool repeat = down && (msg->lParam & (1 << 30)); + + switch (msg->wParam) { + case VK_UP: if (!repeat) { motion.up = down; send_motion(); } return true; + case VK_DOWN: if (!repeat) { motion.down = down; send_motion(); } return true; + case VK_LEFT: if (!repeat) { motion.left = down; send_motion(); } return true; + case VK_RIGHT: if (!repeat) { motion.right = down; send_motion(); } return true; + case VK_ADD: + case VK_OEM_PLUS: + if (down) set_zoom((int)SendMessageW(sld_zoom, TBM_GETPOS, 0, 0) + ZOOM_KEY_STEP); + return true; + case VK_SUBTRACT: + case VK_OEM_MINUS: + if (down) set_zoom((int)SendMessageW(sld_zoom, TBM_GETPOS, 0, 0) - ZOOM_KEY_STEP); + return true; + case 'C': + if (down && !repeat) do_center(); + return true; + case VK_SPACE: + case VK_ESCAPE: + if (down && !repeat) stop_all(); + return true; + } + return false; +} + +// --- Layout --- + +static HWND make(const WCHAR *cls, const WCHAR *text, DWORD style, + int x, int y, int w, int h, int id) { + HWND hwnd = CreateWindowW(cls, text, WS_CHILD | WS_VISIBLE | style, + x, y, w, h, main_wnd, + (HMENU)(INT_PTR)id, + (HINSTANCE)GetWindowLongPtrW(main_wnd, GWLP_HINSTANCE), + NULL); + SendMessageW(hwnd, WM_SETFONT, (WPARAM)gui_font, TRUE); + return hwnd; +} + +static HWND make_slider(int x, int y, int w, int id, + int min, int max, int pos) { + HWND hwnd = make(TRACKBAR_CLASSW, L"", + TBS_HORZ | TBS_AUTOTICKS, x, y, w, 30, id); + SendMessageW(hwnd, TBM_SETRANGE, TRUE, MAKELPARAM(min, max)); + SendMessageW(hwnd, TBM_SETPOS, TRUE, pos); + SendMessageW(hwnd, TBM_SETTICFREQ, (max - min) / 10 ? (max - min) / 10 : 1, 0); + return hwnd; +} + +static void create_controls(void) { + const int dpad = 56, gap = 4; + const int cx = 190; // horizontal center of the 380px client area + const int dpad_top = 44; + + lbl_status = make(L"STATIC", L"Camera: searching...", + SS_CENTER, 12, 14, 356, 20, IDC_LBL_STATUS); + + HWND b; + b = make(L"BUTTON", L"\x25b2", 0, cx - dpad / 2, dpad_top, dpad, dpad, IDC_BTN_UP); + SendMessageW(b, WM_SETFONT, (WPARAM)dpad_font, TRUE); + SetWindowSubclass(b, dpad_proc, IDC_BTN_UP, 0); + + b = make(L"BUTTON", L"\x25c0", 0, cx - dpad / 2 - gap - dpad, + dpad_top + dpad + gap, dpad, dpad, IDC_BTN_LEFT); + SendMessageW(b, WM_SETFONT, (WPARAM)dpad_font, TRUE); + SetWindowSubclass(b, dpad_proc, IDC_BTN_LEFT, 0); + + make(L"BUTTON", L"Stop", 0, cx - dpad / 2, + dpad_top + dpad + gap, dpad, dpad, IDC_BTN_STOP); + + b = make(L"BUTTON", L"\x25b6", 0, cx + dpad / 2 + gap, + dpad_top + dpad + gap, dpad, dpad, IDC_BTN_RIGHT); + SendMessageW(b, WM_SETFONT, (WPARAM)dpad_font, TRUE); + SetWindowSubclass(b, dpad_proc, IDC_BTN_RIGHT, 0); + + b = make(L"BUTTON", L"\x25bc", 0, cx - dpad / 2, + dpad_top + 2 * (dpad + gap), dpad, dpad, IDC_BTN_DOWN); + SendMessageW(b, WM_SETFONT, (WPARAM)dpad_font, TRUE); + SetWindowSubclass(b, dpad_proc, IDC_BTN_DOWN, 0); + + int y = dpad_top + 3 * dpad + 2 * gap + 18; // below the D-pad + + make(L"STATIC", L"Pan speed", 0, 12, y + 5, 78, 20, 0); + sld_pan = make_slider(92, y, 230, IDC_SLD_PAN, 1, PAN_MAX_SPEED, DEFAULT_PAN_SPEED); + lbl_pan = make(L"STATIC", L"", 0, 330, y + 5, 38, 20, IDC_LBL_PAN); + + y += 38; + make(L"STATIC", L"Tilt speed", 0, 12, y + 5, 78, 20, 0); + sld_tilt = make_slider(92, y, 230, IDC_SLD_TILT, 1, TILT_MAX_SPEED, DEFAULT_TILT_SPEED); + lbl_tilt = make(L"STATIC", L"", 0, 330, y + 5, 38, 20, IDC_LBL_TILT); + + y += 38; + make(L"STATIC", L"Zoom", 0, 12, y + 5, 78, 20, 0); + sld_zoom = make_slider(92, y, 230, IDC_SLD_ZOOM, ZOOM_MIN, ZOOM_MAX, ZOOM_MIN); + lbl_zoom = make(L"STATIC", L"1.0x", 0, 330, y + 5, 38, 20, IDC_LBL_ZOOM); + + y += 44; + make(L"BUTTON", L"Center gimbal", 0, 12, y, 130, 30, IDC_BTN_CENTER); + lbl_clients = make(L"STATIC", L"", SS_RIGHT, 150, y + 7, 218, 20, IDC_LBL_CLIENTS); + + update_speed_labels(); +} + +// --- Window proc --- + +static LRESULT CALLBACK wnd_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { + switch (msg) { + case WM_COMMAND: + switch (LOWORD(wp)) { + case IDC_BTN_STOP: + stop_all(); + break; + case IDC_BTN_CENTER: + do_center(); + break; + } + // Return focus to the main window so arrow keys keep working + SetFocus(hwnd); + return 0; + + case WM_HSCROLL: + if ((HWND)lp == sld_zoom) { + set_zoom((int)SendMessageW(sld_zoom, TBM_GETPOS, 0, 0)); + } else { + update_speed_labels(); + if (moving) send_motion(); // apply new speed immediately + } + return 0; + + case WM_TIMER: + if (wp == TIMER_STATUS) update_status(); + return 0; + + case WM_DESTROY: + KillTimer(hwnd, TIMER_STATUS); + PostQuitMessage(0); + return 0; + } + return DefWindowProcW(hwnd, msg, wp, lp); +} + +// --- Public --- + +int gui_run(HINSTANCE instance, int show_cmd) { + INITCOMMONCONTROLSEX icc = { sizeof(icc), ICC_BAR_CLASSES }; + InitCommonControlsEx(&icc); + + NONCLIENTMETRICSW ncm; + ncm.cbSize = sizeof(ncm); + SystemParametersInfoW(SPI_GETNONCLIENTMETRICS, sizeof(ncm), &ncm, 0); + gui_font = CreateFontIndirectW(&ncm.lfMessageFont); + + LOGFONTW big = ncm.lfMessageFont; + big.lfHeight = -20; + dpad_font = CreateFontIndirectW(&big); + + WNDCLASSW wc; + memset(&wc, 0, sizeof(wc)); + wc.lpfnWndProc = wnd_proc; + wc.hInstance = instance; + wc.hCursor = LoadCursorW(NULL, (LPCWSTR)IDC_ARROW); + wc.hIcon = LoadIconW(NULL, (LPCWSTR)IDI_APPLICATION); + wc.hbrBackground = (HBRUSH)(COLOR_BTNFACE + 1); + wc.lpszClassName = L"LinkCtlPanel"; + RegisterClassW(&wc); + + RECT rect = { 0, 0, 380, 400 }; + DWORD style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX; + AdjustWindowRect(&rect, style, FALSE); + + main_wnd = CreateWindowW(L"LinkCtlPanel", L"Insta360 Link Control Panel", + style, CW_USEDEFAULT, CW_USEDEFAULT, + rect.right - rect.left, rect.bottom - rect.top, + NULL, NULL, instance, NULL); + if (!main_wnd) return 1; + + create_controls(); + update_status(); + SetTimer(main_wnd, TIMER_STATUS, STATUS_INTERVAL_MS, NULL); + + ShowWindow(main_wnd, show_cmd); + UpdateWindow(main_wnd); + + MSG msg; + while (GetMessageW(&msg, NULL, 0, 0) > 0) { + if ((msg.message == WM_KEYDOWN || msg.message == WM_KEYUP) && + handle_key(&msg)) { + continue; + } + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + + return (int)msg.wParam; +} diff --git a/host/linkctl-panel/gui.h b/host/linkctl-panel/gui.h new file mode 100644 index 0000000..92b880c --- /dev/null +++ b/host/linkctl-panel/gui.h @@ -0,0 +1,10 @@ +#ifndef GUI_H +#define GUI_H + +#include + +// Create the control panel window and run the message loop until the user +// closes it. Returns the exit code for WinMain. +int gui_run(HINSTANCE instance, int show_cmd); + +#endif diff --git a/host/linkctl-panel/json.c b/host/linkctl-panel/json.c new file mode 100644 index 0000000..d2071dd --- /dev/null +++ b/host/linkctl-panel/json.c @@ -0,0 +1,52 @@ +#include "json.h" + +#include +#include +#include + +// Locate the value following "key": — returns NULL if the key is absent. +static const char *find_value(const char *json, const char *key) { + size_t key_len = strlen(key); + const char *p = json; + + while ((p = strstr(p, key)) != NULL) { + // Must be a quoted key: "key" followed by optional space and ':' + if (p > json && p[-1] == '"' && p[key_len] == '"') { + const char *q = p + key_len + 1; + while (*q == ' ' || *q == '\t') q++; + if (*q == ':') { + q++; + while (*q == ' ' || *q == '\t') q++; + return q; + } + } + p += key_len; + } + return NULL; +} + +int json_get_string(const char *json, const char *key, + char *out, size_t out_size) { + const char *v = find_value(json, key); + if (!v || *v != '"') return -1; + v++; + + size_t i = 0; + while (*v && *v != '"' && i + 1 < out_size) { + if (*v == '\\' && v[1]) v++; // skip escapes, keep escaped char + out[i++] = *v++; + } + out[i] = '\0'; + return (*v == '"') ? 0 : -1; +} + +int json_get_int(const char *json, const char *key, int *out) { + const char *v = find_value(json, key); + if (!v) return -1; + + char *end = NULL; + long val = strtol(v, &end, 10); + if (end == v) return -1; + *out = (int)val; + return 0; +} diff --git a/host/linkctl-panel/json.h b/host/linkctl-panel/json.h new file mode 100644 index 0000000..9aa0291 --- /dev/null +++ b/host/linkctl-panel/json.h @@ -0,0 +1,18 @@ +#ifndef JSON_H +#define JSON_H + +#include + +// Minimal JSON field extraction — just enough for the fixed linkctl +// protocol (flat objects with unique string/integer fields, as produced by +// ArduinoJson on the ESP32). Not a general-purpose parser: no nesting, no +// arrays, no escape handling beyond skipping over escaped quotes. + +// Find "key": "value" and copy the value. Returns 0 on success. +int json_get_string(const char *json, const char *key, + char *out, size_t out_size); + +// Find "key": and parse it. Returns 0 on success. +int json_get_int(const char *json, const char *key, int *out); + +#endif diff --git a/host/linkctl-panel/main.c b/host/linkctl-panel/main.c new file mode 100644 index 0000000..4463b4d --- /dev/null +++ b/host/linkctl-panel/main.c @@ -0,0 +1,57 @@ +// linkctl-panel — Windows control panel for the Insta360 Link. +// +// Single executable, no dependencies, no installer. Controls the camera +// directly over USB/UVC (see camera.c) and runs the same WebSocket + +// mDNS interface as the Mac daemon so the ESP32 joystick keeps working. + +#define WIN32_LEAN_AND_MEAN +#include +#include +#include + +#include "camera.h" +#include "server.h" +#include "mdns.h" +#include "gui.h" + +int WINAPI wWinMain(HINSTANCE instance, HINSTANCE prev, + PWSTR cmd_line, int show_cmd) { + (void)prev; + + // GUI-subsystem apps have no console; --console attaches one so the + // [camera]/[server] logs are visible for debugging. + if (cmd_line && wcsstr(cmd_line, L"--console")) { + if (AllocConsole()) { + FILE *f; + freopen_s(&f, "CONOUT$", "w", stdout); + freopen_s(&f, "CONOUT$", "w", stderr); + } + } + + camera_init(); + if (camera_open() == 0) { + printf("[main] Camera found on startup\n"); + } else { + printf("[main] Camera not found - will keep looking\n"); + } + + bool server_ok = (server_start(DEFAULT_PORT) == 0); + if (!server_ok) { + MessageBoxW(NULL, + L"WebSocket server failed to start (port 9000 in use?).\n" + L"The control panel will work, but the ESP32 controller " + L"won't be able to connect.", + L"linkctl-panel", MB_ICONWARNING | MB_OK); + } + + bool mdns_ok = server_ok && (mdns_register(DEFAULT_PORT) == 0); + + int rc = gui_run(instance, show_cmd); + + camera_stop(); + if (mdns_ok) mdns_unregister(); + server_stop(); + camera_destroy(); + + return rc; +} diff --git a/host/linkctl-panel/mdns.c b/host/linkctl-panel/mdns.c new file mode 100644 index 0000000..d71c69b --- /dev/null +++ b/host/linkctl-panel/mdns.c @@ -0,0 +1,108 @@ +#include "mdns.h" + +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include +#include + +// Widen the (narrow) service-type macro at compile time +#define WIDE_(s) L##s +#define WIDE(s) WIDE_(s) + +static DNS_SERVICE_INSTANCE *instance = NULL; +static HANDLE registered_event = NULL; +static volatile LONG register_status = -1; + +static void WINAPI register_callback(DWORD status, PVOID context, + PDNS_SERVICE_INSTANCE inst) { + (void)context; + if (inst) DnsServiceFreeInstance(inst); + InterlockedExchange(®ister_status, (LONG)status); + if (registered_event) SetEvent(registered_event); +} + +int mdns_register(uint16_t port) { + WCHAR hostname[256] = L"linkctl"; + DWORD len = 255; + GetComputerNameExW(ComputerNameDnsHostname, hostname, &len); + + WCHAR service_name[512]; + WCHAR host_name[512]; + swprintf(service_name, 512, L"%ls." WIDE(MDNS_SERVICE_TYPE) L".local", + hostname); + swprintf(host_name, 512, L"%ls.local", hostname); + + instance = DnsServiceConstructInstance( + service_name, host_name, + NULL, NULL, // IPv4/IPv6 (NULL = let the system fill them in) + port, + 0, 0, // priority, weight + 0, NULL, NULL); // no TXT properties + if (!instance) { + fprintf(stderr, "[mdns] DnsServiceConstructInstance failed\n"); + return -1; + } + + registered_event = CreateEventW(NULL, TRUE, FALSE, NULL); + + DNS_SERVICE_REGISTER_REQUEST req; + memset(&req, 0, sizeof(req)); + req.Version = DNS_QUERY_REQUEST_VERSION1; + req.InterfaceIndex = 0; // all interfaces + req.pServiceInstance = instance; + req.pRegisterCompletionCallback = register_callback; + req.pQueryContext = NULL; + req.unicastEnabled = FALSE; + + DWORD rc = DnsServiceRegister(&req, NULL); + if (rc != DNS_REQUEST_PENDING) { + fprintf(stderr, "[mdns] DnsServiceRegister failed: %lu\n", + (unsigned long)rc); + DnsServiceFreeInstance(instance); + instance = NULL; + return -1; + } + + // The callback reports the actual outcome; wait briefly so failures + // surface in the log, but don't block startup on it. + if (registered_event && + WaitForSingleObject(registered_event, 3000) == WAIT_OBJECT_0) { + if (register_status == ERROR_SUCCESS) { + printf("[mdns] Registered as '%ls' (%s)\n", + service_name, MDNS_SERVICE_TYPE); + } else { + fprintf(stderr, "[mdns] Registration failed: %ld\n", + (long)register_status); + } + } + + return 0; +} + +void mdns_unregister(void) { + if (!instance) return; + + DNS_SERVICE_REGISTER_REQUEST req; + memset(&req, 0, sizeof(req)); + req.Version = DNS_QUERY_REQUEST_VERSION1; + req.InterfaceIndex = 0; + req.pServiceInstance = instance; + req.pRegisterCompletionCallback = register_callback; + req.unicastEnabled = FALSE; + + if (registered_event) ResetEvent(registered_event); + if (DnsServiceDeRegister(&req, NULL) == DNS_REQUEST_PENDING && + registered_event) { + WaitForSingleObject(registered_event, 2000); + } + + DnsServiceFreeInstance(instance); + instance = NULL; + if (registered_event) { + CloseHandle(registered_event); + registered_event = NULL; + } + printf("[mdns] Service unregistered\n"); +} diff --git a/host/linkctl-panel/mdns.h b/host/linkctl-panel/mdns.h new file mode 100644 index 0000000..19d7f7a --- /dev/null +++ b/host/linkctl-panel/mdns.h @@ -0,0 +1,17 @@ +#ifndef MDNS_H +#define MDNS_H + +#include + +// mDNS service type (matches the Mac daemon and ESP32 discovery) +#define MDNS_SERVICE_TYPE "_insta360ctrl._tcp" + +// Register the service via the Windows DNS-SD API (DnsServiceRegister, +// available since Windows 10 1809 — no Bonjour install required). +// Returns 0 on success. +int mdns_register(uint16_t port); + +// Unregister the service. +void mdns_unregister(void); + +#endif diff --git a/host/linkctl-panel/server.c b/host/linkctl-panel/server.c new file mode 100644 index 0000000..710dcec --- /dev/null +++ b/host/linkctl-panel/server.c @@ -0,0 +1,384 @@ +// WebSocket server thread — Winsock select() loop, hand-rolled RFC 6455 +// (see ws.c). Command dispatch and responses mirror the Mac daemon's +// server.c so the ESP32 firmware works against either host unchanged. + +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include +#include +#include + +#include "server.h" +#include "ws.h" +#include "json.h" +#include "camera.h" + +#define MAX_CLIENTS 8 +#define RX_BUF_SIZE 8192 +#define STATUS_POLL_MS 1000 + +typedef enum { + CLIENT_HANDSHAKE, + CLIENT_OPEN, +} client_state_t; + +typedef struct { + SOCKET sock; // INVALID_SOCKET = slot free + client_state_t state; + uint8_t rx[RX_BUF_SIZE]; + size_t rx_len; +} client_t; + +static client_t clients[MAX_CLIENTS]; +static SOCKET listen_sock = INVALID_SOCKET; +static HANDLE server_thread = NULL; +static volatile LONG running = 0; +static volatile LONG client_count = 0; + +// --- Sending --- + +static void client_close(client_t *c) { + if (c->sock != INVALID_SOCKET) { + closesocket(c->sock); + c->sock = INVALID_SOCKET; + if (c->state == CLIENT_OPEN) { + InterlockedDecrement(&client_count); + printf("[server] Client disconnected\n"); + } + c->state = CLIENT_HANDSHAKE; + c->rx_len = 0; + } +} + +static void send_all(client_t *c, const uint8_t *data, size_t len) { + size_t sent = 0; + while (sent < len) { + int n = send(c->sock, (const char *)data + sent, (int)(len - sent), 0); + if (n <= 0) { + client_close(c); + return; + } + sent += (size_t)n; + } +} + +static void send_frame(client_t *c, uint8_t opcode, + const void *payload, size_t len) { + uint8_t buf[WS_MAX_PAYLOAD + 8]; + int n = ws_encode_frame(opcode, payload, len, buf, sizeof(buf)); + if (n > 0) send_all(c, buf, (size_t)n); +} + +static void send_json(client_t *c, const char *json) { + send_frame(c, WS_OP_TEXT, json, strlen(json)); +} + +static void broadcast_json(const char *json) { + for (int i = 0; i < MAX_CLIENTS; i++) { + if (clients[i].sock != INVALID_SOCKET && + clients[i].state == CLIENT_OPEN) { + send_json(&clients[i], json); + } + } +} + +static void format_status(char *buf, size_t size) { + camera_status_t st = camera_get_status(); + snprintf(buf, size, + "{\"type\":\"status\",\"camera_connected\":%s,\"zoom\":%u}", + st.connected ? "true" : "false", st.zoom); +} + +// --- Command dispatch (mirrors linkctl-daemon/server.c) --- + +static void handle_command(client_t *c, const char *json) { + char cmd[32]; + if (json_get_string(json, "cmd", cmd, sizeof(cmd)) != 0) { + send_json(c, "{\"type\":\"error\",\"message\":\"missing cmd field\"}"); + return; + } + + int rc = 0; + + if (strcmp(cmd, "pan_tilt") == 0) { + int pan_dir = 0, pan_speed = 0, tilt_dir = 0, tilt_speed = 0; + json_get_int(json, "pan_dir", &pan_dir); + json_get_int(json, "pan_speed", &pan_speed); + json_get_int(json, "tilt_dir", &tilt_dir); + json_get_int(json, "tilt_speed", &tilt_speed); + + // Speed 0 on both axes means stop — the camera requires the + // explicit stop payload [0,1,0,1], not [0,0,0,0]. + if (pan_speed == 0 && tilt_speed == 0) { + rc = camera_stop(); + } else { + rc = camera_pan_tilt((uint8_t)pan_dir, (uint8_t)pan_speed, + (uint8_t)tilt_dir, (uint8_t)tilt_speed); + } + } else if (strcmp(cmd, "zoom") == 0) { + int value = 0; + if (json_get_int(json, "value", &value) == 0) { + rc = camera_zoom((uint16_t)value); + } else { + send_json(c, "{\"type\":\"error\",\"message\":\"missing zoom value\"}"); + return; + } + } else if (strcmp(cmd, "stop") == 0) { + rc = camera_stop(); + } else if (strcmp(cmd, "center") == 0) { + rc = camera_center(); + } else if (strcmp(cmd, "status") == 0) { + char status[128]; + format_status(status, sizeof(status)); + send_json(c, status); + return; + } else { + send_json(c, "{\"type\":\"error\",\"message\":\"unknown command\"}"); + return; + } + + if (rc != 0) { + send_json(c, "{\"type\":\"error\",\"message\":\"camera not connected\"}"); + } else { + send_json(c, "{\"type\":\"ack\"}"); + } +} + +// --- Per-client input processing --- + +static void process_handshake(client_t *c) { + if (c->rx_len >= RX_BUF_SIZE - 1) { + client_close(c); + return; + } + c->rx[c->rx_len] = '\0'; + + char *end = strstr((char *)c->rx, "\r\n\r\n"); + if (!end) return; // headers incomplete + + char resp[512]; + int n = ws_handshake_response((const char *)c->rx, resp, sizeof(resp)); + if (n < 0) { + const char bad[] = "HTTP/1.1 400 Bad Request\r\n\r\n"; + send_all(c, (const uint8_t *)bad, sizeof(bad) - 1); + client_close(c); + return; + } + + send_all(c, (const uint8_t *)resp, (size_t)n); + if (c->sock == INVALID_SOCKET) return; // send failed + + // Discard the request; anything after the headers begins frame data + size_t consumed = (size_t)(end - (char *)c->rx) + 4; + memmove(c->rx, c->rx + consumed, c->rx_len - consumed); + c->rx_len -= consumed; + c->state = CLIENT_OPEN; + InterlockedIncrement(&client_count); + printf("[server] Client connected\n"); + + // Send initial status (matches the Mac daemon's on-connect behavior) + char status[128]; + format_status(status, sizeof(status)); + send_json(c, status); +} + +static void process_frames(client_t *c) { + for (;;) { + ws_frame_t frame; + int consumed = ws_parse_frame(c->rx, c->rx_len, &frame); + if (consumed == 0) return; // need more data + if (consumed < 0) { + client_close(c); + return; + } + + switch (frame.opcode) { + case WS_OP_TEXT: { + char msg[WS_MAX_PAYLOAD + 1]; + memcpy(msg, frame.payload, frame.payload_len); + msg[frame.payload_len] = '\0'; + handle_command(c, msg); + break; + } + case WS_OP_PING: + send_frame(c, WS_OP_PONG, frame.payload, frame.payload_len); + break; + case WS_OP_CLOSE: + send_frame(c, WS_OP_CLOSE, NULL, 0); + client_close(c); + return; + default: + break; // ignore pong/binary + } + + if (c->sock == INVALID_SOCKET) return; + memmove(c->rx, c->rx + consumed, c->rx_len - (size_t)consumed); + c->rx_len -= (size_t)consumed; + } +} + +static void client_readable(client_t *c) { + if (c->rx_len >= RX_BUF_SIZE) { + client_close(c); + return; + } + + int n = recv(c->sock, (char *)c->rx + c->rx_len, + (int)(RX_BUF_SIZE - c->rx_len), 0); + if (n <= 0) { + client_close(c); + return; + } + c->rx_len += (size_t)n; + + if (c->state == CLIENT_HANDSHAKE) { + process_handshake(c); + } + if (c->state == CLIENT_OPEN && c->sock != INVALID_SOCKET) { + process_frames(c); + } +} + +// --- Server thread --- + +static DWORD WINAPI server_main(LPVOID arg) { + (void)arg; + camera_thread_attach(); + + bool last_connected = false; + ULONGLONG last_poll = 0; + + while (running) { + fd_set readfds; + FD_ZERO(&readfds); + FD_SET(listen_sock, &readfds); + for (int i = 0; i < MAX_CLIENTS; i++) { + if (clients[i].sock != INVALID_SOCKET) { + FD_SET(clients[i].sock, &readfds); + } + } + + struct timeval tv = { 0, 200 * 1000 }; // 200ms + int ready = select(0, &readfds, NULL, NULL, &tv); + if (ready == SOCKET_ERROR) break; // listen socket closed by server_stop + + if (ready > 0 && FD_ISSET(listen_sock, &readfds)) { + SOCKET s = accept(listen_sock, NULL, NULL); + if (s != INVALID_SOCKET) { + int slot = -1; + for (int i = 0; i < MAX_CLIENTS; i++) { + if (clients[i].sock == INVALID_SOCKET) { slot = i; break; } + } + if (slot < 0) { + closesocket(s); + } else { + u_long nonblock = 1; + ioctlsocket(s, FIONBIO, &nonblock); + int nodelay = 1; + setsockopt(s, IPPROTO_TCP, TCP_NODELAY, + (const char *)&nodelay, sizeof(nodelay)); + clients[slot].sock = s; + clients[slot].state = CLIENT_HANDSHAKE; + clients[slot].rx_len = 0; + } + } + } + + for (int i = 0; i < MAX_CLIENTS; i++) { + if (clients[i].sock != INVALID_SOCKET && + FD_ISSET(clients[i].sock, &readfds)) { + client_readable(&clients[i]); + } + } + + // Broadcast a status update when the camera connects or disconnects + ULONGLONG now = GetTickCount64(); + if (now - last_poll >= STATUS_POLL_MS) { + last_poll = now; + bool connected = camera_is_connected(); + if (connected != last_connected) { + last_connected = connected; + char status[128]; + format_status(status, sizeof(status)); + broadcast_json(status); + } + } + } + + for (int i = 0; i < MAX_CLIENTS; i++) { + client_close(&clients[i]); + } + return 0; +} + +// --- Public API --- + +int server_start(uint16_t port) { + WSADATA wsa; + if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) { + fprintf(stderr, "[server] WSAStartup failed\n"); + return -1; + } + + for (int i = 0; i < MAX_CLIENTS; i++) { + clients[i].sock = INVALID_SOCKET; + } + + listen_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (listen_sock == INVALID_SOCKET) { + fprintf(stderr, "[server] socket() failed: %d\n", WSAGetLastError()); + return -1; + } + + BOOL reuse = TRUE; + setsockopt(listen_sock, SOL_SOCKET, SO_REUSEADDR, + (const char *)&reuse, sizeof(reuse)); + + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = INADDR_ANY; + addr.sin_port = htons(port); + + if (bind(listen_sock, (struct sockaddr *)&addr, sizeof(addr)) != 0 || + listen(listen_sock, 4) != 0) { + fprintf(stderr, "[server] bind/listen on port %u failed: %d\n", + port, WSAGetLastError()); + closesocket(listen_sock); + listen_sock = INVALID_SOCKET; + return -1; + } + + running = 1; + server_thread = CreateThread(NULL, 0, server_main, NULL, 0, NULL); + if (!server_thread) { + running = 0; + closesocket(listen_sock); + listen_sock = INVALID_SOCKET; + return -1; + } + + printf("[server] WebSocket server listening on port %u\n", port); + return 0; +} + +void server_stop(void) { + if (!server_thread) return; + + running = 0; + // Closing the listen socket makes select() fail and the thread exit + if (listen_sock != INVALID_SOCKET) { + closesocket(listen_sock); + listen_sock = INVALID_SOCKET; + } + WaitForSingleObject(server_thread, 2000); + CloseHandle(server_thread); + server_thread = NULL; + WSACleanup(); +} + +int server_client_count(void) { + return (int)client_count; +} diff --git a/host/linkctl-panel/server.h b/host/linkctl-panel/server.h new file mode 100644 index 0000000..554f3c6 --- /dev/null +++ b/host/linkctl-panel/server.h @@ -0,0 +1,22 @@ +#ifndef SERVER_H +#define SERVER_H + +#include +#include + +// Default WebSocket port (matches the Mac daemon) +#define DEFAULT_PORT 9000 + +// Start the WebSocket server in a background thread. Returns 0 on success. +// The server speaks the same JSON protocol as linkctl-daemon, so the ESP32 +// firmware works against it unchanged. Camera access is serialized by the +// camera module's internal lock. +int server_start(uint16_t port); + +// Stop the server thread and close all connections. +void server_stop(void); + +// Number of currently connected WebSocket clients (for the GUI status line). +int server_client_count(void); + +#endif diff --git a/host/linkctl-panel/ws.c b/host/linkctl-panel/ws.c new file mode 100644 index 0000000..46d571e --- /dev/null +++ b/host/linkctl-panel/ws.c @@ -0,0 +1,259 @@ +#include "ws.h" + +#include +#include + +// --- SHA-1 (FIPS 180-1) — needed only for the handshake accept key --- + +typedef struct { + uint32_t state[5]; + uint64_t length; // bits + uint8_t block[64]; + size_t block_len; +} sha1_ctx; + +static uint32_t rol32(uint32_t v, int n) { + return (v << n) | (v >> (32 - n)); +} + +static void sha1_init(sha1_ctx *c) { + c->state[0] = 0x67452301; + c->state[1] = 0xEFCDAB89; + c->state[2] = 0x98BADCFE; + c->state[3] = 0x10325476; + c->state[4] = 0xC3D2E1F0; + c->length = 0; + c->block_len = 0; +} + +static void sha1_process(sha1_ctx *c, const uint8_t *p) { + uint32_t w[80]; + for (int i = 0; i < 16; i++) { + w[i] = ((uint32_t)p[i * 4] << 24) | ((uint32_t)p[i * 4 + 1] << 16) | + ((uint32_t)p[i * 4 + 2] << 8) | (uint32_t)p[i * 4 + 3]; + } + for (int i = 16; i < 80; i++) { + w[i] = rol32(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1); + } + + uint32_t a = c->state[0], b = c->state[1], d = c->state[3]; + uint32_t cc = c->state[2], e = c->state[4]; + + for (int i = 0; i < 80; i++) { + uint32_t f, k; + if (i < 20) { f = (b & cc) | ((~b) & d); k = 0x5A827999; } + else if (i < 40) { f = b ^ cc ^ d; k = 0x6ED9EBA1; } + else if (i < 60) { f = (b & cc) | (b & d) | (cc & d); k = 0x8F1BBCDC; } + else { f = b ^ cc ^ d; k = 0xCA62C1D6; } + + uint32_t t = rol32(a, 5) + f + e + k + w[i]; + e = d; + d = cc; + cc = rol32(b, 30); + b = a; + a = t; + } + + c->state[0] += a; + c->state[1] += b; + c->state[2] += cc; + c->state[3] += d; + c->state[4] += e; +} + +static void sha1_update(sha1_ctx *c, const void *data, size_t len) { + const uint8_t *p = data; + c->length += (uint64_t)len * 8; + while (len > 0) { + size_t take = 64 - c->block_len; + if (take > len) take = len; + memcpy(c->block + c->block_len, p, take); + c->block_len += take; + p += take; + len -= take; + if (c->block_len == 64) { + sha1_process(c, c->block); + c->block_len = 0; + } + } +} + +static void sha1_final(sha1_ctx *c, uint8_t digest[20]) { + uint64_t bits = c->length; + + // Pad: 0x80, zeros to 56 mod 64, then the 64-bit big-endian bit count + c->block[c->block_len++] = 0x80; + if (c->block_len > 56) { + memset(c->block + c->block_len, 0, 64 - c->block_len); + sha1_process(c, c->block); + c->block_len = 0; + } + memset(c->block + c->block_len, 0, 56 - c->block_len); + for (int i = 0; i < 8; i++) { + c->block[56 + i] = (uint8_t)(bits >> (56 - i * 8)); + } + sha1_process(c, c->block); + + for (int i = 0; i < 5; i++) { + digest[i * 4] = (uint8_t)(c->state[i] >> 24); + digest[i * 4 + 1] = (uint8_t)(c->state[i] >> 16); + digest[i * 4 + 2] = (uint8_t)(c->state[i] >> 8); + digest[i * 4 + 3] = (uint8_t)(c->state[i]); + } +} + +// --- Base64 encode --- + +static void base64_encode(const uint8_t *in, size_t len, char *out) { + static const char tab[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + size_t i = 0; + while (i + 2 < len) { + *out++ = tab[in[i] >> 2]; + *out++ = tab[((in[i] & 0x03) << 4) | (in[i + 1] >> 4)]; + *out++ = tab[((in[i + 1] & 0x0F) << 2) | (in[i + 2] >> 6)]; + *out++ = tab[in[i + 2] & 0x3F]; + i += 3; + } + if (i + 1 == len) { + *out++ = tab[in[i] >> 2]; + *out++ = tab[(in[i] & 0x03) << 4]; + *out++ = '='; + *out++ = '='; + } else if (i + 2 == len) { + *out++ = tab[in[i] >> 2]; + *out++ = tab[((in[i] & 0x03) << 4) | (in[i + 1] >> 4)]; + *out++ = tab[(in[i + 1] & 0x0F) << 2]; + *out++ = '='; + } + *out = '\0'; +} + +// --- Handshake --- + +// Case-insensitive header lookup. Copies the (trimmed) value into `out`. +static int get_header(const char *request, const char *name, + char *out, size_t out_size) { + size_t name_len = strlen(name); + const char *p = request; + + while ((p = strchr(p, '\n')) != NULL) { + p++; // start of next line + if (_strnicmp(p, name, name_len) == 0 && p[name_len] == ':') { + const char *v = p + name_len + 1; + while (*v == ' ' || *v == '\t') v++; + const char *end = v; + while (*end && *end != '\r' && *end != '\n') end++; + while (end > v && (end[-1] == ' ' || end[-1] == '\t')) end--; + size_t len = (size_t)(end - v); + if (len >= out_size) len = out_size - 1; + memcpy(out, v, len); + out[len] = '\0'; + return 0; + } + } + return -1; +} + +int ws_handshake_response(const char *request, char *resp, size_t resp_size) { + char key[128]; + if (get_header(request, "Sec-WebSocket-Key", key, sizeof(key)) != 0) { + return -1; + } + + // accept = base64(sha1(key + magic GUID)) + static const char magic[] = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + sha1_ctx ctx; + uint8_t digest[20]; + sha1_init(&ctx); + sha1_update(&ctx, key, strlen(key)); + sha1_update(&ctx, magic, sizeof(magic) - 1); + sha1_final(&ctx, digest); + + char accept[32]; + base64_encode(digest, sizeof(digest), accept); + + // Echo the first offered subprotocol (the ESP32 client offers "arduino" + // and expects it back) + char proto_line[160] = ""; + char protos[128]; + if (get_header(request, "Sec-WebSocket-Protocol", protos, sizeof(protos)) == 0) { + char *comma = strchr(protos, ','); + if (comma) *comma = '\0'; + snprintf(proto_line, sizeof(proto_line), + "Sec-WebSocket-Protocol: %s\r\n", protos); + } + + int n = snprintf(resp, resp_size, + "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Accept: %s\r\n" + "%s" + "\r\n", + accept, proto_line); + if (n < 0 || (size_t)n >= resp_size) return -1; + return n; +} + +// --- Framing --- + +int ws_encode_frame(uint8_t opcode, const void *payload, size_t len, + uint8_t *out, size_t out_size) { + size_t header = (len < 126) ? 2 : 4; // server frames are unmasked + if (len > 0xFFFF) return -1; // nothing we send is that large + if (out_size < header + len) return -1; + + out[0] = (uint8_t)(0x80 | (opcode & 0x0F)); // FIN set + if (len < 126) { + out[1] = (uint8_t)len; + } else { + out[1] = 126; + out[2] = (uint8_t)(len >> 8); + out[3] = (uint8_t)(len & 0xFF); + } + if (len > 0) memcpy(out + header, payload, len); + return (int)(header + len); +} + +int ws_parse_frame(uint8_t *buf, size_t len, ws_frame_t *frame) { + if (len < 2) return 0; + + uint8_t fin = (buf[0] >> 7) & 1; + uint8_t opcode = buf[0] & 0x0F; + uint8_t masked = (buf[1] >> 7) & 1; + uint64_t plen = buf[1] & 0x7F; + size_t pos = 2; + + if (!masked) return -1; // RFC 6455: client frames must be masked + + if (plen == 126) { + if (len < pos + 2) return 0; + plen = ((uint64_t)buf[pos] << 8) | buf[pos + 1]; + pos += 2; + } else if (plen == 127) { + if (len < pos + 8) return 0; + plen = 0; + for (int i = 0; i < 8; i++) plen = (plen << 8) | buf[pos + i]; + pos += 8; + } + + if (plen > WS_MAX_PAYLOAD) return -1; + + if (len < pos + 4) return 0; + uint8_t mask[4]; + memcpy(mask, buf + pos, 4); + pos += 4; + + if (len < pos + plen) return 0; + + for (uint64_t i = 0; i < plen; i++) { + buf[pos + i] ^= mask[i & 3]; + } + + frame->fin = fin; + frame->opcode = opcode; + frame->payload = buf + pos; + frame->payload_len = (size_t)plen; + return (int)(pos + plen); +} diff --git a/host/linkctl-panel/ws.h b/host/linkctl-panel/ws.h new file mode 100644 index 0000000..8481d36 --- /dev/null +++ b/host/linkctl-panel/ws.h @@ -0,0 +1,45 @@ +#ifndef WS_H +#define WS_H + +// Minimal server-side RFC 6455 WebSocket codec — just enough to speak to +// the ESP32's WebSocketsClient (links2004/arduinoWebSockets). No +// fragmentation or extensions; text/close/ping/pong opcodes only. + +#include +#include + +#define WS_OP_CONT 0x0 +#define WS_OP_TEXT 0x1 +#define WS_OP_BIN 0x2 +#define WS_OP_CLOSE 0x8 +#define WS_OP_PING 0x9 +#define WS_OP_PONG 0xA + +// Max payload accepted from a client (commands are tiny JSON objects) +#define WS_MAX_PAYLOAD 4096 + +typedef struct { + uint8_t opcode; + uint8_t fin; + uint8_t *payload; // Points into the input buffer (unmasked in place) + size_t payload_len; +} ws_frame_t; + +// Build the 101 Switching Protocols response for an upgrade request. +// `request` must be the full NUL-terminated HTTP request (through the blank +// line). Echoes the first requested subprotocol if one was offered. +// Returns response length, or -1 if the request is not a valid upgrade. +int ws_handshake_response(const char *request, char *resp, size_t resp_size); + +// Encode a server->client frame (unmasked). Returns total frame length, +// or -1 if `out` is too small. +int ws_encode_frame(uint8_t opcode, const void *payload, size_t len, + uint8_t *out, size_t out_size); + +// Parse one client frame from `buf`. Returns: +// >0 bytes consumed (frame complete; payload unmasked in place) +// 0 need more data +// -1 protocol error (oversized, unmasked client frame, etc.) +int ws_parse_frame(uint8_t *buf, size_t len, ws_frame_t *frame); + +#endif