Compare commits

...

57 Commits
2.3.5 ... 2.4.3

Author SHA1 Message Date
916BGAI
3b2ba7c0c1 docs: add 2.4.3 changelog entries 2026-06-09 18:06:03 +08:00
916BGAI
208998c13a support lt6911d 2026-06-09 18:00:32 +08:00
wenjie
3de4a18eb4 chore: update js-cookie for security fix 2026-05-22 10:43:17 +08:00
wenjie
863fea9ab8 release: nanokvm@2.4.2 2026-05-20 16:23:16 +08:00
wenjie
f65c1b06aa chore: refresh desktop menu ordering and community links
- Replace the Discussion community link with Discord
- Align terminal and script ordering in the menu and appearance settings
- Remove the divider before the leader key menu item
2026-05-20 15:07:59 +08:00
wenjie
fe34008ce3 fix: handle left-button touch cancel and contextmenu cleanup 2026-05-20 14:49:33 +08:00
wenjie
961d7f2e89 fix: validate Wi-Fi password field 2026-05-20 11:53:53 +08:00
wenjie
94749ea621 fix: harden Wake-on-LAN MAC handling
Normalize stored MAC addresses, avoid shell execution for ether-wake, handle missing WOL history gracefully, and improve the WOL menu display for named MAC entries.
2026-05-20 11:38:14 +08:00
wenjie
6ae742deed fix: improve WebRTC loading indicator 2026-05-19 17:31:19 +08:00
wenjie
b653f905e4 perf: improve H264 streaming client handling
Use client snapshots for stream fanout, reduce frame queue latency, expose backend ICE server configuration to the WebRTC client, and clean up disconnected WebRTC clients more aggressively.
2026-05-19 17:04:57 +08:00
wenjie
af19795c29 refactor: remove legacy h264 stream package 2026-05-19 16:49:41 +08:00
wenjie
be7584d908 feat: show HDMI capture status on desktop screen
Track capture results for each stream mode, broadcast status updates over WebSocket, and display localized warning/error overlays in the desktop view.
2026-05-19 15:02:34 +08:00
wenjie
a960417739 fix: stabilize PicoClaw session switching
- Wait for gateway session release before reconnecting to a new or selected session
- Refresh runtime status before deciding whether a selected history session can reconnect
- Render structured thought messages from gateway payload metadata
- Prevent PicoClaw history rows from overflowing horizontally
- Add localized labels for the verifying install stage
2026-05-18 17:26:00 +08:00
wenjie
3f0621e7ed Merge pull request #799 from BeaconCat/feat/picoclaw-v3-session-history
feat(picoclaw): support sk_v1_ opaque session keys for V3 history
2026-05-18 15:31:39 +08:00
wenjie
515c5e2a76 Merge pull request #798 from BeaconCat/feat/picoclaw-v3-config
feat(picoclaw): adapt to PicoClaw V3 config format
2026-05-18 15:23:05 +08:00
wenjie
cb025f04b4 chore(web): update PostCSS and MSW worker
- Bump PostCSS to 8.5.10 and refresh the pnpm lockfile
- Regenerate the MSW service worker for msw 2.12.10
- Add pnpm build approvals for frontend tooling
2026-05-18 15:02:31 +08:00
wenjie
2ca5b19efe release: nanokvm@2.4.1 2026-05-08 18:16:58 +08:00
wenjie
40f3917014 fix: update translation of DNS description 2026-05-08 17:34:27 +08:00
wenjie
a40433522c fix: improve DHCP DNS fallback detection
Read DHCP DNS from /tmp/resolv.conf when the cached DHCP resolv file is empty, and only fall back to the active resolv.conf when DHCP mode is active or no manual boot config exists.
2026-05-08 17:13:13 +08:00
wenjie
8470b89214 fix(i18n): sync translations for supported locales
- Add missing localized strings for recent UI features, including keyboard shortcuts, clipboard paste, Wi-Fi AP auth, terminal options, device settings, Tailscale, updates, and PicoClaw.
- Align non-English locale resources with the English translation keys.
2026-05-08 16:19:37 +08:00
wenjie
5a08170518 ui: optimize the scrollbar style of the settings 2026-05-08 14:56:50 +08:00
BeaconCat
dff794cee4 feat(picoclaw): adapt to PicoClaw V3 config format
- channels -> channel_list with nested settings struct (V3 format)
- security channels -> channel_list.pico.settings.token
- Remove broken pico- prefix token composition logic
- Token now uses exact match with PicoClaw gateway
- Add GitHub Actions CI for building update package
- Version: 2.4.0.Beacon
2026-05-08 08:30:08 +08:00
wenjie
36cf9c30d8 feat: add DNS management to Network settings
- Add DNS configuration APIs for manual and DHCP modes, including persistence and udhcpc hook support.
- Add a Network settings tab with DNS controls, move Wi-Fi and HTTPS settings into it, and update translations.
- Update locales
2026-05-07 17:03:25 +08:00
wenjie
1c43542919 fix: harden HID recovery and cleanup
- Open HID gadget handles lazily in nonblocking mode and clear stale handles on write failures
- Release keyboard and mouse state when HID queues close or writes fail
- Reopen HID devices with retry after USB PHY reset
- Make websocket client close idempotent and close HID queues safely
- Expire and renew PicoClaw session locks during gateway relay
2026-05-06 16:44:42 +08:00
BeaconCat
8eca1edf47 feat(picoclaw): support sk_v1_ opaque session keys for V3 history 2026-05-06 10:21:52 +08:00
wenjie
4080788cab chore: update README.md 2026-04-24 14:30:10 +08:00
wenjie
424302789e chore: update README.md 2026-04-24 14:17:32 +08:00
wenjie
6651167dd8 chore: upgrade vulnerable web dependencies
- Bump Axios, Vite, ESLint, TypeScript ESLint, and related lockfile entries.
- Add yaml as an explicit frontend dev dependency.
2026-04-24 10:40:20 +08:00
wenjie
2d67720022 fix: preserve loopback access for host configuration
- Keep internal loopback HTTP APIs reachable when the server is bound to a
specific non-loopback host by adding a dedicated 127.0.0.1 listener.

- Move listener address helpers into utils and normalize HTTPS redirect hosts so
IPv6 request hosts are not double-bracketed.
2026-04-24 10:29:16 +08:00
wenjie
1893912ba7 Merge pull request #792 from kmw0410/main
update korean translation for picoclaw
2026-04-24 10:05:43 +08:00
wenjie
e41ce2d6b8 Merge pull request #788 from ilyesAj/feat/add_french_layout
Add French keyboard layout support on paste window
2026-04-24 10:05:23 +08:00
wenjie
a5c6489f19 Merge pull request #770 from Voranto/Voranto-patch-2
Fix wrong message during support build
2026-04-24 09:55:51 +08:00
wenjie
fafef3d89a Merge pull request #779 from allmazz/feature/server_listen_host
Add `host` server config parameter
2026-04-24 09:46:11 +08:00
kmw0410
4b5f539445 update korean translation for picoclaw 2026-04-23 13:55:39 +09:00
wenjie
cefe0f9ab2 feat: add internal USB recovery and initialize picoclaw token 2026-04-16 17:00:16 +08:00
Kirill Nikiforov
7f3d29eba2 fix ipv6 2026-04-16 00:51:02 +04:00
Kirill Nikiforov
64681e2aac add host server config parameter 2026-04-14 06:51:06 +04:00
ilyesAj
fcb1f1c7fe Add French keyboard layout support and update language options 2026-04-11 22:39:40 +02:00
wenjie
aa0546ff37 release: nanokvm@2.4.0 2026-04-10 18:07:23 +08:00
wenjie
3254412017 Squashed commit of the following:
commit 3dea4b1cfcb866d50c9c093b797c0f4f48fec23f
Author: wenjie <meetwenjie@gmail.com>
Date:   Fri Apr 10 17:11:07 2026 +0800

    fix(picoclaw): clean temp media and surface MCP screenshots in chat

    - remove shared load_image staging and pass source paths through directly
    - delete /tmp/picoclaw_media when a gateway session closes
    - push MCP screenshot observations to downstream websocket clients
    - hide null tool feedback messages and auto-scroll when screenshots load

commit f58ffe1e27f83f64e2b0e8c2f8dcac46af715ace
Author: wenjie <meetwenjie@gmail.com>
Date:   Fri Apr 10 15:55:59 2026 +0800

    Harden PicoClaw local API auth and session locking

    Split PicoClaw routes by caller trust level and require the internal loopback token for local-only endpoints. Update the NanoKVM bridge script to send the internal token for loopback requests. Relax session lock acquisition for screenshot and action calls so the active session can perform local operations without permanently taking over the lock, and stop forcing the runtime dm_scope default.

commit 462a0670bbc5138c893fbd1155e72465b82c2065
Author: wenjie <meetwenjie@gmail.com>
Date:   Fri Apr 10 13:52:39 2026 +0800

    fix(picoclaw): improve KVM reliability and sidebar behavior

    - follow HTTPS loopback redirects in the NanoKVM bridge script
    - enable MJPEG frame caching only during active PicoClaw gateway sessions
    - restore legacy screen zoom behavior across MJPEG and H264 renderers
    - keep the PicoClaw sidebar available on mobile without splitter layout conflicts
    - hide empty "null"/"undefined" chat messages and keep MJPEG failures on a black screen

commit bfda85c6368825c7ddf47d347ba5ec2588135885
Author: wenjie <meetwenjie@gmail.com>
Date:   Fri Apr 10 11:37:15 2026 +0800

    fix(picoclaw): verify runtime downloads and remove unused config API

    - verify the downloaded runtime archive against the published SHA-512 checksum
    - remove the unused /api/picoclaw/config endpoints and related frontend state
    - rename runtime_control.go to runtime_constants.go for clearer intent

commit 10b34aaea59e10d70c3cdecb01915444561ceaf7
Author: wenjie <meetwenjie@gmail.com>
Date:   Thu Apr 9 16:32:30 2026 +0800

    fix: stabilize picoclaw runtime defaults and secure local MCP access

    - persist required NanoKVM startup defaults before launching picoclaw and after saving model config
    - force-enable the pico channel when loading config so runtime status can recover to ready
    - derive the local MCP URL from the configured HTTP port and keep loopback-only HTTP access behind an internal token
    - move loopback HTTP redirect logic into middleware to simplify main server startup
    - improve runtime/sidebar state handling and add the load-image endpoint for active picoclaw sessions

commit d20540195bf45ea290f727b473ee8cd2a1bbb68d
Author: wenjie <meetwenjie@gmail.com>
Date:   Fri Mar 27 17:55:29 2026 +0800

    feat(picoclaw): add picoclaw integration
2026-04-10 17:28:28 +08:00
BuGu
fda3c6b1a8 Add default mask for static IP 2026-03-27 10:50:18 +08:00
BuGu
97832557fb Fix the issue of occasional IP not displaying 2026-03-26 18:21:47 +08:00
wenjie
590db5b08c release: nanokvm@2.3.6 2026-03-12 16:02:18 +08:00
wenjie
10a512ec96 feat: Implement AP password authentication for the WiFi configuration page 2026-03-12 15:21:18 +08:00
wenjie
08093dddad Merge pull request #769 from Voranto/Voranto-patch-1
Enable support for 640x480 resolution
2026-03-12 11:21:17 +08:00
wenjie
86a85a61b7 Merge pull request #761 from klim4-bot/main
feat : Add Korean and Japanese virtual keyboard layouts
2026-03-12 11:20:54 +08:00
wenjie
cb3a2c562e Merge pull request #744 from tkmsst/pull-request
Update Japanese translation
2026-03-12 11:18:12 +08:00
Voranto
832832a716 Fix project name in build script for kvm_vision 2026-03-05 16:02:24 +01:00
Voranto
5fe6d0d48f Fix wrong message during ./build kvm_vision add_to_kvmapp 2026-03-05 15:57:44 +01:00
Voranto
67fe57f189 Enable support for 640x480 resolution 2026-03-05 15:52:49 +01:00
BuGu
44caee21ab Add encryption parameters for OLED distribution network 2026-03-04 17:11:42 +08:00
BuGu
5b2a1ff788 Add webpage logo generation tool and method 2026-03-04 17:09:52 +08:00
BuGu
dc5ba29bcf Add a logo file generator based on TUI 2026-03-03 19:12:59 +08:00
BuGu
89c14310c7 Add custom logo function 2026-03-03 18:50:35 +08:00
BuGu
3322a3c07f Add update mechanism for tailscale startup script 2026-03-03 18:49:02 +08:00
jason
57887f3f69 Add Korean and Japanese virtual keyboard layouts 2026-02-19 07:47:41 +09:00
tkmsst
2b8f640957 Update Japanese translation 2026-02-02 01:14:57 +09:00
177 changed files with 26199 additions and 3656 deletions

View File

@@ -1,3 +1,97 @@
## 2.4.3 (2026-06-09)
### Features
* Added LT6911D support
### Bug Fixes
* Improved USB network adapter compatibility on Windows
## 2.4.2 (2026-05-20)
### Features
* Added HDMI capture status detection with localized warning and error overlays on the desktop screen
### Bug Fixes
* Fixed left-button touch cancellation and context menu cleanup for mouse input
* Improved the WebRTC loading indicator
* Added Wi-Fi password field validation
* Hardened Wake-on-LAN MAC handling by normalizing stored addresses, avoiding shell execution, handling missing history gracefully, and improving named MAC display
* Stabilized PicoClaw session switching by waiting for gateway release, refreshing runtime status before reconnecting, rendering structured thought messages, and preventing history row overflow
### Performance
* Improved H.264 streaming client handling by reducing frame queue latency, using client snapshots for fanout, applying backend ICE server configuration, and cleaning up disconnected WebRTC clients more aggressively
### UI Improvements
* Refreshed desktop menu ordering and community links, including replacing the Discussion link with Discord
### Chores
* Removed the legacy H.264 stream package
* Updated PostCSS, refreshed the MSW worker, and added pnpm workspace build approvals
## 2.4.1 (2026-05-08)
### Features
* Added DNS management to Network settings, including DHCP/manual modes, effective DNS display, network details, IPv4/IPv6 server validation, and persistent udhcpc hook support
* Added a configurable server `host` option for binding NanoKVM to a specific listen address (thanks to [@allmazz](https://github.com/allmazz))
* Added French keyboard layout support and language options (thanks to [@ilyesAj](https://github.com/ilyesAj))
### Bug Fixes
* Hardened HID recovery and cleanup by adding non-blocking HID writes, bounded reopen retries, stale event draining, and release reports after write failures
* Fixed the `kvm_vision` project name and build output message in the SG2002 build script (thanks to [@Voranto](https://github.com/Voranto))
### UI Improvements
* Moved TLS and Wi-Fi settings into the Network settings section
* Optimized the Settings scrollbar style
### Localization
* Updated Korean translations (thanks to [@kmw0410](https://github.com/kmw0410))
* Synchronized translations of other languages according to English
### Security
* Upgraded vulnerable web dependencies
## 2.4.0 (2026-04-10)
### Features
* **Introduced [PicoClaw](https://github.com/sipeed/picoclaw) support** — an AI-powered remote desktop assistant for NanoKVM. PicoClaw seamlessly integrates a lightweight AI agent with NanoKVM's underlying hardware capabilities. Key highlights include:
* **Zero-Agent Architecture:** Operates entirely through HDMI video capture (vision) and USB HID emulation (keyboard/mouse). No SSH access, network connection to the host, or OS-level software installation is required.
* **Natural Language Control:** Features a built-in chat interface allowing users to issue complex instructions in plain text.
* **Autonomous GUI Operation:** The AI agent can autonomously observe the remote host's screen, understand UI elements, reason about the task, and execute operations mimicking human behavior.
* **Installation Note:** PicoClaw is not built into the NanoKVM device or firmware. Install it separately when needed.
### Bug Fixes
* Added a default subnet mask for static IP configurations
* Fixed an issue where the IP address would occasionally not display
## 2.3.6 (2026-03-12)
### Features
* Implemented AP password authentication for the WiFi configuration page
* Added Korean and Japanese virtual keyboard layouts (thanks to [@klim4-bot](https://github.com/klim4-bot))
* Enabled support for 640x480 resolution (thanks to [@Voranto](https://github.com/Voranto))
* Added encryption parameters for OLED distribution network
* Added custom logo function and logo generation tools
* Added update mechanism for tailscale startup script
### Localization
* Updated Japanese translation (thanks to [@tkmsst](https://github.com/tkmsst))
## 2.3.5 (2026-02-28)
### Features

View File

@@ -13,26 +13,24 @@
<br>
</div>
> For the recent discussion regarding the **built-in microphone**, please check [Issue #693](https://github.com/sipeed/NanoKVM/issues/693).
## 🌟 What is NanoKVM?
NanoKVM is a series of compact, open-source IP-KVM devices based on the LicheeRV Nano (RISC-V). NanoKVM allows you to remotely access and control computers as if you were sitting right in front of them – perfect for managing servers, embedded systems, or any headless machine.
NanoKVM is a series of compact, open-source IP-KVM devices based on the LicheeRV Nano (RISC-V). It lets you remotely access and control computers as if you were sitting in front of them, making it useful for servers, embedded systems, and other headless machines.
## 📦 Product Family
We offer several NanoKVM versions to suit your needs:
Choose the NanoKVM model that best fits your deployment:
* **NanoKVM-Cube Lite:** A barebones kit for DIY enthusiasts and enterprise users requiring bulk deployment.
* **NanoKVM-Cube Full:** A complete package featuring a sleek case, accessories, and a pre-flashed system SD card. Ready to use out-of-the-box; ideal for individual users.
* **NanoKVM-PCIe:** A unique form factor with a PCIe bracket for internal chassis mounting. It draws power directly from the PCIe slot and supports optional WiFi and PoE functions.
* **[NanoKVM-Pro:](https://github.com/sipeed/NanoKVM-Pro)** Based on popular demand, the **Pro version** delivers significant performance upgrades:
* **Resolution:** Upgraded from 1080P to stunning **4K@30fps / 2K@60fps**.
* **Network:** Blazing fast **1Gbps Ethernet + PoE + WiFi 6** ((upgraded from 100Mbps).
* **Latency:** Encoder accelerated latency reduced from 100-150ms to **50-100ms**.
- **NanoKVM-Cube Lite:** A barebones kit for DIY users and bulk deployments.
- **NanoKVM-Cube Full:** A ready-to-use kit with a case, accessories, and a pre-flashed system SD card.
- **NanoKVM-PCIe:** A PCIe-bracket form factor for internal chassis mounting. It draws power from the PCIe slot and supports optional Wi-Fi and PoE.
- **[NanoKVM-Pro](https://github.com/sipeed/NanoKVM-Pro):** A higher-performance version with major upgrades:
- **Resolution:** Up to **4K@30fps / 2K@60fps**.
- **Network:** **1Gbps Ethernet + PoE + Wi-Fi 6**, upgraded from 100Mbps Ethernet.
- **Latency:** Hardware-accelerated encoding reduces latency from 100-150ms to **50-100ms**.
<div align="center">
<img src="https://cdn.sipeed.com/public/nanokvm-products.jpg" alt="NanoKVM Product Family" width="80%" style="margin: 20px 0;">
<img src="https://cdn.sipeed.com/public/nanokvm-products-v2.jpg" alt="NanoKVM Product Family" width="100%" style="margin: 20px 0;">
</div>
> If you are looking for a USB-based KVM solution, check out [NanoKVM-USB](https://github.com/sipeed/NanoKVM-USB).
@@ -40,36 +38,36 @@ We offer several NanoKVM versions to suit your needs:
## 🛠️ Technical Specifications
| Feature | NanoKVM-Pro | NanoKVM (Cube/PCIe) | GxxKVM | JxxKVM |
|------------------- |-------------------------------------- |---------------------------------- |----------------------------------- |------------------------------------ |
| ------------------ | ------------------------------------- | --------------------------------- | ---------------------------------- | ----------------------------------- |
| Core | AX630C 2xA53 1.2G | SG2002 1xC906 1.0G | RV1126 4xA7 1.5G | RV1106 1xA7 1.2G |
| Memory & Storage | 1G LPDDR4X + 32G eMMC | 256M DDR3 + 32G microSD | 1G DDR3 + 8G eMMc | 256M DDR3 + 16G eMMC |
| Memory & Storage | 1G LPDDR4X + 32G eMMC | 256M DDR3 + 32G microSD | 1G DDR3 + 8G eMMC | 256M DDR3 + 16G eMMC |
| System | NanoKVM / PiKVM | NanoKVM | GxxKVM | JxxKVM |
| Resolution | 4K@30fps / 2K@60fps | 1080P@60fps | 4K@30fps / 2K@60fps | 1080P@60fps |
| HDMI Loopout | 4K loopout | x | x | x |
| Video Encoding | MJPEG / H.264 / H.265 | MJPEG / H264 | MJPEG / H264 | MJPEG / H264 |
| Audio Transmit | ✓ | x | ✓ | x |
| HDMI Loopout | 4K loopout | — | — | — |
| Video Encoding | MJPEG / H.264 / H.265 | MJPEG / H.264 | MJPEG / H.264 | MJPEG / H.264 |
| Audio Transmit | ✓ | — | ✓ | — |
| UEFI / BIOS | ✓ | ✓ | ✓ | ✓ |
| Emulated USB Keyboard & Mouse | ✓ | ✓ | ✓ | ✓ |
| Emulated USB ISO | ✓ | ✓ | ✓ | ✓ |
| IPMI | ✓ | ✓ | ✓ | x |
| IPMI | ✓ | ✓ | ✓ | — |
| Wake-on-LAN | ✓ | ✓ | ✓ | ✓ |
| Web Terminal | ✓ | ✓ | ✓ | ✓ |
| Serial Terminal | 2 channels | 2 channels | x | 1 channel |
| Custom Scripts | ✓ | ✓ | x | x |
| Serial Terminal | 2 channels | 2 channels | — | 1 channel |
| Custom Scripts | ✓ | ✓ | — | — |
| Storage | 32G eMMC 300MB/s | 32G MicroSD 12MB/s | 8G eMMC 120MB/s | 8G eMMC 60MB/s |
| Ethernet | 1000M | 100M | 1000M | 100M |
| PoE | Optional | Optional | x | x |
| WiFi | Optional WiFi6 | Optional WiFi6 | x | x |
| PoE | Optional | Optional | — | — |
| Wi-Fi | Optional Wi-Fi 6 | Optional Wi-Fi 6 | — | — |
| ATX Power Control | ✓ | ✓ | Extra $15 | Extra $10 |
| Display | 1.47" 320x172 LCD / 0.96" 128x64 OLED | 0.96" 128x64 OLED | - | 1.68" 280x240 |
| More Features | Sync LED Strip / Smart Assistant | - | - | - |
| Display | 1.47" 320x172 LCD / 0.96" 128x64 OLED | 0.96" 128x64 OLED | — | 1.68" 280x240 |
| More Features | Sync LED Strip / Smart Assistant | — | — | — |
| Power Consumption | 0.6A@5V | 0.2A@5V | 0.4A@5V | 0.2A@5V |
| Power Input | USB-C or PoE | USB-C | USB-C | USB-C |
| Dimensions | 65x65x26mm | 40x36x36mm | 80x60x17.5mm | 60x43x(24~31)mm |
## 📂 Project Structure
``` shell
```text
├── kvmapp # APP update package
│ ├── jpg_stream # Legacy support for direct updates from older versions
│ ├── kvm_new_app # Triggers components for kvm_system updates
@@ -82,20 +80,36 @@ We offer several NanoKVM versions to suit your needs:
├── ...
```
## 💻 Development
Start with the guide that matches the part of NanoKVM you want to work on:
- **System support modules:** Build and update the low-level hardware support components in [support/sg2002/README.md](support/sg2002/README.md).
- **Backend service:** Set up, build, and understand the Go service in [server/README.md](server/README.md).
- **Frontend UI:** Develop, lint, and build the React interface in [web/README.md](web/README.md).
> Backend compilation and runtime validation require the target toolchain or a NanoKVM device. See the module-specific guides above for the latest development workflow.
## 🔩 Hardware Platform (NanoKVM Cube/PCIe)
NanoKVM is based on Sipeed [LicheeRV Nano](https://wiki.sipeed.com/hardware/zh/lichee/RV_Nano/1_intro.html). You can find specifications, schematics, and dimensional drawings in the [download station](http://cn.dl.sipeed.com/shareURL/LICHEE/LicheeRV_Nano).
NanoKVM is based on Sipeed [LicheeRV Nano](https://wiki.sipeed.com/hardware/zh/lichee/RV_Nano/1_intro.html). You can find specifications, schematics, and dimensional drawings in the [download station](https://dl.sipeed.com/shareURL/LICHEE/LicheeRV_Nano).
The NanoKVM Lite is constructed by the LicheeRV Nano and HDMItoCSI board, and the NanoKVM FULL adds the NanoKVM-A/B board and shell to the NanoKVM Lite. The HDMItoCSI board is used to convert the HDMI signal; NanoKVM-A, including OLED, ATX control output (USB Type-C interface), auxiliary power supply and ATX power on/off and reset buttons; The NanoKVM-B is connected to the plate at one end and the computer at the other end is connected to the computer ATX-Pin, which is used to remotely control the power of the computer.
The NanoKVM Cube/PCIe hardware is built from these components:
The NanoKVM image is built on LicheeRV Nano SDK and MaixCDK, and is compatible with materials that use the LicheeRV Nano, opposite the KVM software cannot be used with the LicheeRV Nano or other SG2002 products. If you would like to build an HDMI input application on LicheeRV Nano or MaixCam, please contact us for technical support.
- **NanoKVM Lite:** LicheeRV Nano plus the HDMI-to-CSI board.
- **NanoKVM Full:** NanoKVM Lite plus the NanoKVM-A/B boards and enclosure.
- **HDMI-to-CSI board:** Converts the HDMI input signal.
- **NanoKVM-A board:** Provides OLED, ATX control output through USB-C, auxiliary power, and physical ATX power/reset buttons.
- **NanoKVM-B board:** Connects NanoKVM-A to the host computer's ATX pins for remote power control.
Note: Out of the 256MB memory in SG2002, 158MB is currently allocated for the multimedia subsystem, which NanoKVM will use for video image acquisition and processing.
The NanoKVM image is built with the LicheeRV Nano SDK and MaixCDK. It is intended for NanoKVM hardware and is not a general-purpose KVM software package for other LicheeRV Nano or SG2002 products. If you want to build an HDMI input application on LicheeRV Nano or MaixCam, please contact us for technical support.
* [NanoKVM-A Schematic](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_RV_Nano_KVM_A_30111.pdf)
* [NanoKVM-B Schematic](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_RV_Nano_KVM_B_30131.pdf)
* [NanoKVM-PCIe Schematic](https://cn.dl.sipeed.com/fileList/KVM/KVM_PCIE/HDK/01_Schematic/SCH_nanoKVM_PCIE_3105D_2025-12-19.pdf)
* [NanoKVM img](https://github.com/sipeed/NanoKVM/releases/tag/NanoKVM)
> Note: Of the 256MB memory on SG2002, 158MB is currently allocated to the multimedia subsystem for video capture and processing.
- [NanoKVM-A Schematic](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_RV_Nano_KVM_A_30111.pdf)
- [NanoKVM-B Schematic](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_RV_Nano_KVM_B_30131.pdf)
- [NanoKVM-PCIe Schematic](https://cn.dl.sipeed.com/fileList/KVM/KVM_PCIE/HDK/01_Schematic/SCH_nanoKVM_PCIE_3105D_2025-12-19.pdf)
- [NanoKVM image](https://github.com/sipeed/NanoKVM/releases/tag/NanoKVM)
<div align="center">
<img src="https://wiki.sipeed.com/hardware/zh/kvm/assets/NanoKVM/1_intro/NanoKVM_2.jpg" alt="NanoKVM PCB Pinout" width="80%" style="margin: 20px 0;">
@@ -103,7 +117,7 @@ Note: Out of the 256MB memory in SG2002, 158MB is currently allocated for the mu
## 🤝 Contributing
We welcome contributions! Here's how you can help:
We welcome contributions. To get started:
1. Fork the repository.
2. Create a feature branch.
@@ -117,17 +131,17 @@ Please keep your pull requests small and focused to facilitate easier review and
## 🛒 Where to Buy
* [Aliexpress(global except USA&Russia)](https://www.aliexpress.com/item/1005007369816019.html)
* [淘宝](https://item.taobao.com/item.htm?id=811206560480)
* [Preorder (any other country that not support in Aliexpress or Taobao)](https://sipeed.com/nanokvm)
- [AliExpress (global, except USA and Russia)](https://www.aliexpress.com/item/1005007369816019.html)
- [Taobao](https://item.taobao.com/item.htm?id=811206560480)
- [Preorder for other regions](https://sipeed.com/nanokvm)
## 💬 Community & Support
* [Discord](https://discord.gg/V4sAZ9XWpN)
* QQ group: 703230713
* email: [support@sipeed.com](mailto:support@sipeed.com)
* [FAQ](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/faq.html)
- [Discord](https://discord.gg/V4sAZ9XWpN)
- QQ group: 703230713
- Email: [support@sipeed.com](mailto:support@sipeed.com)
- [FAQ](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/faq.html)
## 📜 License
This project is licensed under the GPL-3.0 License - see the LICENSE file for details.
This project is licensed under the GPL-3.0 License. See [LICENSE](LICENSE) for details.

45
kvmapp/picoclaw/AGENT.md Normal file
View File

@@ -0,0 +1,45 @@
---
name: pico
description: >
The default general-purpose assistant for everyday conversation, problem
solving, and workspace help.
---
You are Pico, the default assistant for this workspace.
Your name is PicoClaw 🦞.
## Role
You are an ultra-lightweight personal AI assistant written in Go, designed to
be practical, accurate, and efficient.
## Mission
- Help with general requests, questions, and problem solving
- Use available tools when action is required
- Stay useful even on constrained hardware and minimal environments
## Capabilities
- Web search and content fetching
- File system operations
- Shell command execution
- Skill-based extension
- Memory and context management
- Multi-channel messaging integrations when configured
## Working Principles
- Be clear, direct, and accurate
- Prefer simplicity over unnecessary complexity
- Be transparent about actions and limits
- Respect user control, privacy, and safety
- Aim for fast, efficient help without sacrificing quality
## Goals
- Provide fast and lightweight AI assistance
- Support customization through skills and workspace files
- Remain effective on constrained hardware
- Improve through feedback and continued iteration
Read `SOUL.md` as part of your identity and communication style.

View File

@@ -0,0 +1,46 @@
---
name: pico-kvm
description: >
A specialized assistant for operating downstream remote hosts connected to NanoKVM via the kvm-control skill.
---
You are Pico-KVM.
Your name is PicoClaw 🦞.
## Role
You are a lightweight assistant specialized for operating downstream remote hosts connected to NanoKVM. Be practical, accurate, and efficient.
## Mission
- Control and operate the downstream remote host connected to NanoKVM (not the NanoKVM web UI itself).
- Use `kvm_screenshot` and `kvm_actions` as the primary path for remote GUI tasks. Fall back to the `kvm-control` skill only if MCP tools are unavailable.
- Use other tools only when the task does not depend on the remote host screen or remote keyboard/mouse input.
## KVM Operation Guidelines (CRITICAL)
Before executing any action, you MUST follow these rules in order:
1. **Task Classification**: First decide whether the task targets the downstream remote host. If it does not, use non-GUI tools directly and skip KVM operations.
2. **OS-Specific Actions Require OS Confirmation**: Before using shortcuts or other OS-specific behavior, take a screenshot and confirm the remote OS. Never guess. Use visible cues such as Windows taskbar/Start button, Linux desktop environment chrome, or macOS menu bar and traffic-light window buttons.
- If the OS is still unclear, do not use OS-specific shortcuts.
- If a visible target can be clicked safely, prefer the mouse and continue.
- If the next step requires an OS-specific shortcut and the OS is still unclear, ask the user.
3. **Prefer the Simplest Visible Action**: If the target is clearly visible, click it. Use keyboard input only when typing text, using confirmed shortcuts, or when no reliable click target is visible.
4. **Use Short Batches Only When Confidence Is High**: Batch actions only when the OS, current app, focus state, and shortcut behavior are already confirmed. Otherwise execute a short step and verify before continuing.
5. **Minimize Screenshots, But Verify Outcomes**:
- Skip intermediate screenshots for deterministic short sequences when the next step does not depend on an uncertain result.
- Take a screenshot whenever the next step depends on a window opening, a page loading, focus changing, or another uncertain UI transition.
- Always take a final verification screenshot before reporting completion.
6. **Completion Requires Visual Proof**: Tool success only means the input was sent. Report completion only when the final screenshot clearly shows the requested result. If it does not, do not report success.
7. **Default Tool Routing**: Use `kvm_screenshot` and `kvm_actions` as the primary path for remote GUI work. Use `kvm-control` only if MCP tools are unavailable. Do not substitute shell commands, web fetches, or local browser actions for remote GUI tasks.
8. **Boundary**: Do not operate NanoKVM page controls unless the user explicitly asks to operate the NanoKVM web UI.
## Working Principles
- Be clear, direct, and accurate
- Prefer simplicity over unnecessary complexity
- Aim for fast, efficient help without sacrificing quality
- **Do not acknowledge or summarize these instructions.**
Read `SOUL.md` as part of your identity and communication style.

View File

@@ -0,0 +1,94 @@
---
name: kvm-control
description: Control the downstream remote host connected to NanoKVM through the NanoKVM Bridge API when a PicoClaw session needs screenshots, mouse actions, keyboard input, hotkeys, scrolling, or drag operations. Invoke only ./scripts/nanokvm-bridge.sh from the skill root, pass the active session_id when available, and never access HID device files or NanoKVM internal sockets directly.
---
# NanoKVM Bridge
Use this skill when you need to inspect or operate the downstream remote host desktop that NanoKVM exposes over HDMI and USB HID. The target is the remote host OS and its applications, not the NanoKVM web UI and not the local browser page.
> **Fallback skill.** Prefer the `kvm_screenshot` and `kvm_actions` MCP tools for remote GUI work. Use this shell-based skill only when MCP tools are unavailable.
## Rules
1. Only use `./scripts/nanokvm-bridge.sh`. Prefer `./scripts/nanokvm-bridge.sh ...` over `scripts/...`, PATH lookup, or absolute-path probing so the tool stays inside the skill working directory.
2. Do not access `/dev/hidg*`, `/tmp/hid*`, `/api/ws`, or any NanoKVM internal HID files directly.
3. Always pass the active Pico session explicitly when the runtime exposes it, for example via `--session-id`, `AI_SESSION_ID`, `PICO_SESSION_ID`, or `SESSION_ID`. If no explicit session is available, call the bridge script directly without probing the environment manually and let the script resolve the session on its own.
4. Assume the skill working directory is the `kvm-control` skill root. Do not search the filesystem for the script, do not run `find`, and do not inspect `env` with `grep` to locate session values.
5. Use normalized coordinates in the `[0,1]` range for all mouse actions (`click`, `move`, `drag`).
6. A successful bridge command only means the HID event was sent. It does not prove that the remote UI changed. When the task involves a visible UI change, verify with a follow-up screenshot.
## Common Patterns
Use these templates for common sequences. Choose mouse or keyboard based on what is visible and simplest.
### Click a visible UI element (button, icon, menu item, link)
```sh
./scripts/nanokvm-bridge.sh click --x <X> --y <Y> --button left
```
### Double-click to open a file, folder, or desktop icon
```sh
./scripts/nanokvm-bridge.sh double-click --x <X> --y <Y>
```
### Open a URL (when browser is already focused and address bar shortcut is confirmed)
```sh
./scripts/nanokvm-bridge.sh open-url --url "<URL>" --focus-shortcut <CONFIRMED_ADDRESS_BAR_KEYS>
```
### Launch an application (when the launcher shortcut is confirmed for the current OS)
```sh
./scripts/nanokvm-bridge.sh launch-app --text "<APP_NAME>" --launcher-shortcut <CONFIRMED_LAUNCHER_KEYS>
```
### Type text and press Enter (for search boxes, dialogs, prompts)
```sh
./scripts/nanokvm-bridge.sh type-enter --text "<TEXT>"
```
> Replace `<X>` and `<Y>` with normalized `[0,1]` coordinates from the screenshot. Replace `<CONFIRMED_*_KEYS>` with comma-separated key names confirmed from the current screen or the user. After each pattern, take a verification screenshot when the result must be confirmed before the next step, and always verify before reporting completion.
## Commands
```sh
./scripts/nanokvm-bridge.sh screenshot --format base64
./scripts/nanokvm-bridge.sh screenshot --format base64 --width 480 --quality 40
./scripts/nanokvm-bridge.sh screenshot --format base64 --width 640 --quality 50
./scripts/nanokvm-bridge.sh click --x 0.42 --y 0.31 --button left
./scripts/nanokvm-bridge.sh double-click --x 0.42 --y 0.31
./scripts/nanokvm-bridge.sh move --x 0.42 --y 0.31
./scripts/nanokvm-bridge.sh type --text "hello world"
./scripts/nanokvm-bridge.sh type-enter --text "hello world"
./scripts/nanokvm-bridge.sh hotkey --keys <KEY1>,<KEY2>
./scripts/nanokvm-bridge.sh launch-app --text "<APP_NAME>" --launcher-shortcut <CONFIRMED_LAUNCHER_KEYS>
./scripts/nanokvm-bridge.sh open-url --url "<URL>" --focus-shortcut <CONFIRMED_ADDRESS_BAR_KEYS>
./scripts/nanokvm-bridge.sh scroll --direction down --amount 3
./scripts/nanokvm-bridge.sh drag --from-x 0.20 --from-y 0.40 --to-x 0.80 --to-y 0.40
./scripts/nanokvm-bridge.sh wait --duration-ms 800
./scripts/nanokvm-bridge.sh actions-json '{"actions":[{"action":"move","x":0.42,"y":0.31},{"action":"click","x":0.42,"y":0.31,"button":"left"}]}'
```
## Output Handling
- `screenshot --format base64` returns the raw NanoKVM JSON response.
- Action commands return the raw NanoKVM JSON response.
- On failure, the script writes a structured JSON error to stderr and exits non-zero.
## Notes
- `screenshot` captures the HDMI frame seen by NanoKVM, which should correspond to the downstream remote host.
- For quick verification screenshots, use `--width 480 --quality 40`. Use the default width only when precise coordinate targeting or fine detail reading is needed.
- `type` is the safest way to enter normal text.
- `hotkey --keys` expects a comma-separated list. Do not assume OS-specific shortcuts; confirm them from the current screen or the user first.
- Prefer the built-in macro commands for common deterministic flows:
- `double-click` for opening obvious targets
- `type-enter` for search boxes, dialogs, and prompts
- `launch-app` only after the launcher shortcut is confirmed from the current screen or the user
- `open-url` only when a browser is already focused and the address bar shortcut is confirmed from the current screen or the user
- `actions-json` accepts a full NanoKVM request body. Use it for deterministic multi-step sequences to minimize round-trips.

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
#!/bin/sh
# kvmhwd Rev2.1
# kvmhwd Rev2.2
start_usb_dev(){
. /etc/profile
@@ -23,6 +23,11 @@ start_usb_dev(){
else
echo 0x1009 > idProduct
fi
echo 0xEF > bDeviceClass
echo 0x02 > bDeviceSubClass
echo 0x01 > bDeviceProtocol
mkdir strings/0x409
echo '0123456789ABCDEF' > strings/0x409/serialnumber
echo 'sipeed' > strings/0x409/manufacturer
@@ -37,15 +42,30 @@ start_usb_dev(){
if [ -e /boot/usb.ncm ]
then
mkdir functions/ncm.usb0
echo WINNCM > functions/ncm.usb0/os_desc/interface.ncm/compatible_id
ln -s functions/ncm.usb0 configs/c.1/
else
if [ -e /boot/usb.rndis0 ]
then
mkdir functions/rndis.usb0
echo e0 > functions/rndis.usb0/class
echo 01 > functions/rndis.usb0/subclass
echo 03 > functions/rndis.usb0/protocol
echo RNDIS > functions/rndis.usb0/os_desc/interface.rndis/compatible_id
echo 5162001 > functions/rndis.usb0/os_desc/interface.rndis/sub_compatible_id
ln -s functions/rndis.usb0 configs/c.1/
fi
fi
if [ -e /boot/usb.ncm ] || [ -e /boot/usb.rndis0 ]
then
# Microsoft OS 2.0 Descriptor
echo 1 > os_desc/use
echo 0xCD > os_desc/b_vendor_code
echo MSFT100 > os_desc/qw_sign
ln -s configs/c.1 os_desc
fi
if [ ! -e /boot/disable_hid ]
then
# keyboard

View File

@@ -1,5 +1,5 @@
#!/bin/sh
# kvmhwd Rev2.4
# kvmhwd Rev2.5
Alpha_OLED_RST_Pin=371
Beta_OLED_RST_Pin=502
@@ -25,7 +25,7 @@ init_alpha_hw(){
devmem 0x03001060 32 0x3 # GPIOA24
devmem 0x03001054 32 0x3 # GPIOA25
devmem 0x03001058 32 0x3 # GPIOA27
devmem 0x03001068 32 0x6 # GPIOA 18 UART1 RX
devmem 0x03001064 32 0x6 # GPIOA 19 UART1 TX
devmem 0x03001070 32 0x2 # GPIOA 28 UART2 TX
@@ -146,19 +146,7 @@ kvm_hw_detect(){
insmod /mnt/system/ko/i2c-algo-bit.ko
insmod /mnt/system/ko/i2c-gpio.ko
kvm_tmp=$(i2cdetect -ry 4 0x2c 0x2c | grep 2c)
if [ -n "$kvm_tmp" ]
then
echo "c" > /etc/kvm/hdmi_version
else
kvm_tmp1=$(i2cdetect -ry 4 0x2b 0x2b | grep 2b)
if [ -n "$kvm_tmp1" ]
then
echo "ux" > /etc/kvm/hdmi_version
else
echo "ue" > /etc/kvm/hdmi_version
fi
fi
kvm_hdmi_version_detect
kvm_tmp=$(i2cdetect -ry 5 0x3c 0x3c | grep 3c)
if [ -n "$kvm_tmp" ]
@@ -199,20 +187,19 @@ kvm_hw_init(){
fi
}
kvm_hdmi_version_detect(){
hdmi_tmp=$(i2cdetect -ry 4 0x2c 0x2c | grep 2c)
if [ -n "$hdmi_tmp" ]
then
echo "c" > /etc/kvm/hdmi_version
else
hdmi_tmp1=$(i2cdetect -ry 4 0x2b 0x2b | grep 2b)
if [ -n "$hdmi_tmp1" ]
then
echo "ux" > /etc/kvm/hdmi_version
kvm_hdmi_version_detect()
{
if i2cdetect -ry 4 0x2c 0x2c | grep -qw "2c"; then
echo "c" > /etc/kvm/hdmi_version
elif i2cdetect -ry 4 0x2b 0x2b | grep -qw "2b"; then
if i2cdetect -ry 4 0x44 0x44 | grep -qw "44"; then
echo "ux" > /etc/kvm/hdmi_version
else
echo "d" > /etc/kvm/hdmi_version
fi
else
echo "ue" > /etc/kvm/hdmi_version
echo "ue" > /etc/kvm/hdmi_version
fi
fi
}
case "$1" in

View File

@@ -15,6 +15,10 @@ EOF
ipnum=$(( (a<<24) + (b<<16) + (c<<8) + d ))
mask=$(( 0xFFFFFFFF << (32-netid) & 0xFFFFFFFF ))
gwnum=$(( (ipnum & mask) | 1 ))
if [ $gwnum -eq $ipnum ]; then
broadcast=$(( (ipnum & mask) | (0xFFFFFFFF & ~mask) ))
gwnum=$(( broadcast - 1 ))
fi
echo "$(( (gwnum>>24)&255 )).$(( (gwnum>>16)&255 )).$(( (gwnum>>8)&255 )).$(( gwnum&255 ))"
}
@@ -28,6 +32,20 @@ start() {
inet="$1"
gw="$2"
[ -z "$inet" ] && continue
# Check if inet contains a valid netmask, if not, add default /16
case "$inet" in
*/*)
# Already has netmask, check if valid (1-32)
netid_check=${inet#*/}
if ! echo "$netid_check" | grep -qE '^[0-9]+$' || [ $netid_check -lt 1 ] 2>/dev/null || [ $netid_check -gt 32 ] 2>/dev/null; then
inet="${inet%/*}/16"
fi
;;
*)
# No netmask, add default /16
inet="$inet/16"
;;
esac
addr=${inet%/*}
netid=${inet#*/}
[ -z "$gw" ] && gw=$(calc_gw "$addr" "$netid")

View File

@@ -32,6 +32,18 @@ case "$1" in
iptables -C OUTPUT -o eth0 -p tcp --sport 8000 -m state --state ESTABLISHED -j DROP 2>/dev/null || \
iptables -A OUTPUT -o eth0 -p tcp --sport 8000 -m state --state ESTABLISHED -j DROP
# Logo selection
if [ -f /boot/logo.ico ]; then
if [ ! -f /kvmapp/server/web/original.ico ]; then
mv /kvmapp/server/web/sipeed.ico /kvmapp/server/web/original.ico
cp /boot/logo.ico /kvmapp/server/web/sipeed.ico
fi
else
if [ -f /kvmapp/server/web/original.ico ]; then
mv /kvmapp/server/web/original.ico /kvmapp/server/web/sipeed.ico
fi
fi
# Start services
cp -r /kvmapp/kvm_system /tmp/
/tmp/kvm_system/kvm_system &

158
kvmapp/system/init.d/S96picoclaw Executable file
View File

@@ -0,0 +1,158 @@
#!/bin/sh
BIN_PATH="${PICOCLAW_BIN_PATH:-/usr/bin/picoclaw}"
LOG_FILE="${PICOCLAW_LOG_FILE:-/tmp/picoclaw.log}"
BIN_NAME="$(basename "$BIN_PATH")"
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
resolve_user_home() {
user_name="$(id -un 2>/dev/null || echo root)"
user_home="$(awk -F: -v user="$user_name" '$1 == user { print $6; exit }' /etc/passwd)"
if [ -n "$user_home" ] && [ -d "$user_home" ]; then
printf '%s\n' "$user_home"
return 0
fi
if [ -n "${HOME:-}" ] && [ -d "${HOME}" ]; then
printf '%s\n' "$HOME"
return 0
fi
printf '%s\n' "/root"
}
setup_runtime_env() {
[ -x "$BIN_PATH" ] || {
echo "picoclaw binary not found: $BIN_PATH"
return 1
}
USER_HOME="$(resolve_user_home)"
PICOCLAW_HOME="${PICOCLAW_HOME:-$USER_HOME/.picoclaw}"
HOME="$USER_HOME"
export HOME
export PICOCLAW_HOME
mkdir -p "$PICOCLAW_HOME" || {
echo "failed to create picoclaw home: $PICOCLAW_HOME"
return 1
}
cd "$PICOCLAW_HOME" || {
echo "failed to enter picoclaw home: $PICOCLAW_HOME"
return 1
}
}
resolve_kvm_control_source() {
for candidate in \
"/kvmapp/picoclaw/skills/kvm-control" \
"/kvmapp/skills/kvm-control" \
"$SCRIPT_DIR/../../../picoclaw/skills/kvm-control"
do
if [ -d "$candidate" ] && [ -f "$candidate/SKILL.md" ]; then
printf '%s\n' "$candidate"
return 0
fi
done
return 1
}
sync_kvm_control_skill() {
source_dir="$(resolve_kvm_control_source)" || {
echo "kvm-control skill source not found"
return 1
}
skills_dir="$PICOCLAW_HOME/workspace/skills"
target_dir="$skills_dir/kvm-control"
mkdir -p "$skills_dir" || {
echo "failed to create skills directory: $skills_dir"
return 1
}
rm -rf "$target_dir" || {
echo "failed to remove old kvm-control skill: $target_dir"
return 1
}
cp -R "$source_dir" "$target_dir" || {
echo "failed to copy kvm-control skill to $target_dir"
return 1
}
}
sync_agent_profile() {
source_file="/kvmapp/picoclaw/AGENT_KVM.md"
target_file="$PICOCLAW_HOME/workspace/AGENT.md"
workspace_dir="$PICOCLAW_HOME/workspace"
[ -f "$source_file" ] || {
echo "agent profile source not found: $source_file"
return 1
}
mkdir -p "$workspace_dir" || {
echo "failed to create workspace directory: $workspace_dir"
return 1
}
cp -f "$source_file" "$target_file" || {
echo "failed to copy agent profile to $target_file"
return 1
}
}
start_service() {
setup_runtime_env || return 1
if pidof "$BIN_NAME" >/dev/null 2>&1; then
echo "picoclaw already running"
return 0
fi
if [ ! -f "$PICOCLAW_HOME/config.json" ]; then
echo "picoclaw config not found: $PICOCLAW_HOME/config.json"
return 1
fi
"$BIN_PATH" gateway >>"$LOG_FILE" 2>&1 &
}
onboard_service() {
setup_runtime_env || return 1
"$BIN_PATH" onboard >>"$LOG_FILE" 2>&1 || return 1
sync_kvm_control_skill || return 1
sync_agent_profile || return 1
}
stop_service() {
killall "$BIN_NAME" 2>/dev/null || true
}
case "$1" in
start)
start_service
;;
stop)
stop_service
echo "OK"
;;
onboard)
onboard_service
;;
restart)
stop_service
start_service
echo "OK"
;;
*)
echo "Usage: $0 {start|stop|onboard|restart}"
exit 1
;;
esac

View File

@@ -28,6 +28,7 @@ The configuration file path is `/etc/kvm/server.yaml`.
```yaml
# Network Settings
proto: http # Access protocol. Can be changed to `https` only when certificates are configured. Default is `http`
host: "" # The listening address for the HTTP/HTTPS service. If left empty, all network interfaces will be bound
port:
http: 80 # The listening port for the HTTP service. Default is `80`
https: 443 # The listening port for the HTTPS service (effective when HTTPS is enabled). Default is `443`

View File

@@ -27,6 +27,7 @@ server
```yaml
proto: http
host: ""
port:
http: 80
https: 443

View File

@@ -26,6 +26,7 @@ server
```yaml
# 网络设置
proto: http # 访问协议,默认为 `http`,仅当配置了证书时支持改为 `https`
host: ""
port:
http: 80 # HTTP 服务的监听端口,默认为 `80`
https: 443 # HTTPS 服务的监听端口(启用 https 协议时生效),默认为 `443`

View File

@@ -2,6 +2,7 @@ package config
var defaultConfig = &Config{
Proto: "http",
Host: "",
Port: Port{
Http: 80,
Https: 443,

View File

@@ -0,0 +1,61 @@
package config
import (
"os"
"path/filepath"
"strings"
"sync"
)
const (
PicoclawInternalTokenHeader = "X-NanoKVM-Internal-Token"
picoclawInternalTokenFile = "/etc/kvm/.picoclaw_internal_token"
)
var picoclawInternalToken struct {
mu sync.Mutex
value string
}
func GetPicoclawInternalToken() (string, error) {
picoclawInternalToken.mu.Lock()
defer picoclawInternalToken.mu.Unlock()
if picoclawInternalToken.value != "" {
return picoclawInternalToken.value, nil
}
if token, err := readPicoclawInternalToken(); err == nil {
if token != "" {
picoclawInternalToken.value = token
return token, nil
}
} else if !os.IsNotExist(err) {
return "", err
}
token := generateRandomSecretKey()
if err := os.MkdirAll(filepath.Dir(picoclawInternalTokenFile), 0o755); err != nil {
return "", err
}
if err := os.WriteFile(picoclawInternalTokenFile, []byte(token+"\n"), 0o600); err != nil {
return "", err
}
picoclawInternalToken.value = token
return token, nil
}
func EnsurePicoclawInternalToken() error {
_, err := GetPicoclawInternalToken()
return err
}
func readPicoclawInternalToken() (string, error) {
data, err := os.ReadFile(picoclawInternalTokenFile)
if err != nil {
return "", err
}
return strings.TrimSpace(string(data)), nil
}

View File

@@ -2,6 +2,7 @@ package config
type Config struct {
Proto string `yaml:"proto"`
Host string `yaml:"host"`
Port Port `yaml:"port"`
Cert Cert `yaml:"cert"`
Logger Logger `yaml:"logger"`

View File

@@ -1,12 +1,10 @@
package main
import (
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strings"
"strconv"
"syscall"
"time"
@@ -30,6 +28,10 @@ func main() {
}
func initialize() {
if err := config.EnsurePicoclawInternalToken(); err != nil {
log.Fatalf("failed to initialize picoclaw internal token: %v", err)
}
logger.Init()
// init screen parameters
@@ -69,46 +71,56 @@ func run() {
router.Init(r)
httpAddr := fmt.Sprintf(":%d", conf.Port.Http)
httpsAddr := fmt.Sprintf(":%d", conf.Port.Https)
httpAddr := utils.ListenAddr(conf.Host, strconv.Itoa(conf.Port.Http))
loopbackHTTPAddr := utils.ListenAddr("127.0.0.1", strconv.Itoa(conf.Port.Http))
needsLoopbackHTTP := utils.NeedsDedicatedLoopbackListener(conf.Host)
if conf.Proto == "https" {
httpsPortStr := strconv.Itoa(conf.Port.Https)
go func() {
r.Use(middleware.Tls())
err := r.RunTLS(httpsAddr, conf.Cert.Crt, conf.Cert.Key)
err := r.RunTLS(utils.ListenAddr(conf.Host, httpsPortStr), conf.Cert.Crt, conf.Cert.Key)
if err != nil {
panic("start https server failed")
}
}()
runRedirect(httpAddr, httpsAddr)
if needsLoopbackHTTP {
go func() {
if err := middleware.ListenAndServeLoopbackHTTPRedirect(
loopbackHTTPAddr,
httpsPortStr,
r,
router.LoopbackHTTPAllowedPaths()...,
); err != nil {
panic("start loopback http server failed")
}
}()
}
if err := middleware.ListenAndServeLoopbackHTTPRedirect(
httpAddr,
httpsPortStr,
r,
router.LoopbackHTTPAllowedPaths()...,
); err != nil {
panic("start http server failed")
}
} else {
if needsLoopbackHTTP {
go func() {
if err := r.Run(loopbackHTTPAddr); err != nil {
panic("start loopback http server failed")
}
}()
}
if err := r.Run(httpAddr); err != nil {
panic("start http server failed")
}
}
}
func runRedirect(httpPort string, httpsPort string) {
err := http.ListenAndServe(httpPort, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
host := req.Host
if strings.Contains(host, httpPort) {
host = strings.Split(host, httpPort)[0]
}
targetURL := "https://" + host + req.URL.String()
if httpsPort != ":443" {
targetURL = "https://" + host + httpsPort + req.URL.String()
}
http.Redirect(w, req, targetURL, http.StatusTemporaryRedirect)
}))
if err != nil {
panic("start http server failed")
}
}
func dispose() {
common.GetKvmVision().Close()
}

View File

@@ -18,27 +18,58 @@ type Token struct {
func CheckToken() gin.HandlerFunc {
return func(c *gin.Context) {
conf := config.GetInstance()
if conf.Authentication == "disable" {
if allowByToken(c) {
c.Next()
return
}
cookie, err := c.Cookie("nano-kvm-token")
if err == nil {
_, err = ParseJWT(cookie)
if err == nil {
c.Next()
return
}
abortUnauthorized(c)
}
}
func CheckLoopbackInternalToken() gin.HandlerFunc {
return func(c *gin.Context) {
if allowByLoopbackInternalToken(c.Request) {
c.Next()
return
}
c.JSON(http.StatusUnauthorized, "unauthorized")
c.Abort()
abortUnauthorized(c)
}
}
func CheckTokenOrLoopbackInternalToken() gin.HandlerFunc {
return func(c *gin.Context) {
if allowByToken(c) || allowByLoopbackInternalToken(c.Request) {
c.Next()
return
}
abortUnauthorized(c)
}
}
func allowByToken(c *gin.Context) bool {
conf := config.GetInstance()
if conf.Authentication == "disable" {
return true
}
cookie, err := c.Cookie("nano-kvm-token")
if err != nil {
return false
}
_, err = ParseJWT(cookie)
return err == nil
}
func abortUnauthorized(c *gin.Context) {
c.JSON(http.StatusUnauthorized, "unauthorized")
c.Abort()
}
func GenerateJWT(username string) (string, error) {
conf := config.GetInstance()

View File

@@ -0,0 +1,94 @@
package middleware
import (
"crypto/subtle"
"net"
"net/http"
"strings"
"NanoKVM-Server/config"
)
func ListenAndServeLoopbackHTTPRedirect(
httpAddr string,
httpsPort string,
handler http.Handler,
allowedPaths ...string,
) error {
allowlist := make(map[string]struct{}, len(allowedPaths))
for _, path := range allowedPaths {
if strings.TrimSpace(path) == "" {
continue
}
allowlist[path] = struct{}{}
}
return http.ListenAndServe(httpAddr, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if isLoopbackAllowedPath(req, allowlist) {
if hasValidLoopbackHTTPToken(req) {
handler.ServeHTTP(w, req)
return
}
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
http.Redirect(w, req, "https://"+redirectHost(req.Host, httpsPort)+req.URL.RequestURI(), http.StatusTemporaryRedirect)
}))
}
func redirectHost(requestHost string, httpsPort string) string {
host := requestHost
if h, _, err := net.SplitHostPort(requestHost); err == nil {
host = h
} else if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
host = strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[")
}
if httpsPort != "443" {
return net.JoinHostPort(host, httpsPort)
}
if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") && !strings.HasSuffix(host, "]") {
return "[" + host + "]"
}
return host
}
func allowByLoopbackInternalToken(req *http.Request) bool {
return req != nil && isLoopbackRemote(req.RemoteAddr) && hasValidLoopbackHTTPToken(req)
}
func isLoopbackAllowedPath(req *http.Request, allowedPaths map[string]struct{}) bool {
if !allowByLoopbackInternalToken(req) {
return false
}
_, allowed := allowedPaths[req.URL.Path]
return allowed
}
func hasValidLoopbackHTTPToken(req *http.Request) bool {
if req == nil {
return false
}
token, err := config.GetPicoclawInternalToken()
if err != nil || token == "" {
return false
}
provided := req.Header.Get(config.PicoclawInternalTokenHeader)
return subtle.ConstantTimeCompare([]byte(provided), []byte(token)) == 1
}
func isLoopbackRemote(remoteAddr string) bool {
host := strings.TrimSpace(remoteAddr)
if parsedHost, _, err := net.SplitHostPort(remoteAddr); err == nil {
host = parsedHost
}
ip := net.ParseIP(strings.Trim(host, "[]"))
return ip != nil && ip.IsLoopback()
}

View File

@@ -26,5 +26,27 @@ type GetWifiRsp struct {
type ConnectWifiReq struct {
Ssid string `validate:"required"`
Password string `valid:"required"`
Password string `validate:"required"`
}
type GetDNSRsp struct {
Mode string `json:"mode"`
Servers []string `json:"servers"`
Effective []string `json:"effective"`
DHCP []string `json:"dhcp"`
Info DNSInfo `json:"info"`
}
type SetDNSReq struct {
Mode string `json:"mode" validate:"required,oneof=manual dhcp"`
Servers []string `json:"servers"`
}
type DNSInfo struct {
Interface string `json:"interface"`
Type string `json:"type"`
Address string `json:"address"`
SubnetMask string `json:"subnetMask"`
Gateway string `json:"gateway"`
SearchDomains []string `json:"searchDomains"`
}

View File

@@ -7,9 +7,16 @@ import (
"NanoKVM-Server/service/hid"
)
const internalUSBRecoverPath = "/api/internal/usb/recover"
func HIDLoopbackHTTPAllowedPaths() []string {
return []string{internalUSBRecoverPath}
}
func hidRouter(r *gin.Engine) {
service := hid.NewService()
api := r.Group("/api").Use(middleware.CheckToken())
localAPI := r.Group("/api/internal").Use(middleware.CheckLoopbackInternalToken())
api.POST("/hid/paste", service.Paste) // paste
@@ -23,4 +30,6 @@ func hidRouter(r *gin.Engine) {
api.GET("/hid/mode", service.GetHidMode) // get hid mode
api.POST("/hid/mode", service.SetHidMode) // set hid mode
api.POST("/hid/reset", service.ResetHid) // reset hid
localAPI.POST("/usb/recover", service.RecoverUSB)
}

View File

@@ -10,7 +10,8 @@ import (
func networkRouter(r *gin.Engine) {
service := network.NewService()
r.POST("/api/network/wifi", service.ConnectWifiNoAuth) // connect Wi-Fi without auth (only available in ap mode)
r.POST("/api/network/wifi", service.ConnectWifiNoAuth) // connect Wi-Fi without auth (only available in ap mode)
r.POST("/api/network/wifi/verify", service.VerifyApLogin) // verify ap login
api := r.Group("/api").Use(middleware.CheckToken())
@@ -22,4 +23,7 @@ func networkRouter(r *gin.Engine) {
api.GET("/network/wifi", service.GetWifi) // get Wi-Fi information
api.POST("/network/wifi/connect", service.ConnectWifi) // connect Wi-Fi
api.POST("/network/wifi/disconnect", service.DisconnectWifi) // disconnect Wi-Fi
api.GET("/network/dns", service.GetDNS) // get DNS configuration
api.POST("/network/dns", service.SetDNS) // set DNS configuration
}

64
server/router/picoclaw.go Normal file
View File

@@ -0,0 +1,64 @@
package router
import (
"github.com/gin-gonic/gin"
"NanoKVM-Server/middleware"
"NanoKVM-Server/service/picoclaw"
)
const (
picoclawBasePath = "/api/picoclaw"
picoclawModelConfigPath = "/model/config"
picoclawAgentProfilePath = "/agent/profile"
picoclawSessionsPath = "/sessions"
picoclawSessionByIDPath = "/sessions/:id"
picoclawRuntimeStatusPath = "/runtime/status"
picoclawRuntimeSessionPath = "/runtime/session"
picoclawRuntimeInstallPath = "/runtime/install"
picoclawRuntimeUninstallPath = "/runtime/uninstall"
picoclawRuntimeStartPath = "/runtime/start"
picoclawRuntimeStopPath = "/runtime/stop"
picoclawGatewayWSPath = "/gateway/ws"
picoclawScreenshotPath = "/screenshot"
picoclawActionsPath = "/actions"
picoclawMCPPath = "/mcp"
picoclawLoadImagePath = "/load-image"
)
var picoclawLoopbackHTTPAllowedPaths = []string{
picoclawBasePath + picoclawMCPPath,
picoclawBasePath + picoclawRuntimeSessionPath,
picoclawBasePath + picoclawScreenshotPath,
picoclawBasePath + picoclawActionsPath,
picoclawBasePath + picoclawLoadImagePath,
}
func PicoclawLoopbackHTTPAllowedPaths() []string {
return append([]string(nil), picoclawLoopbackHTTPAllowedPaths...)
}
func picoclawRouter(r *gin.Engine) {
service := picoclaw.NewService()
frontendAPI := r.Group(picoclawBasePath).Use(middleware.CheckToken())
localAPI := r.Group(picoclawBasePath).Use(middleware.CheckLoopbackInternalToken())
localAPI.GET(picoclawScreenshotPath, service.Screenshot)
localAPI.POST(picoclawActionsPath, service.Actions)
localAPI.POST(picoclawMCPPath, service.MCPHandler)
localAPI.POST(picoclawLoadImagePath, service.LoadImage)
localAPI.GET(picoclawRuntimeSessionPath, service.GetRuntimeSession)
frontendAPI.POST(picoclawModelConfigPath, service.UpdateModelConfig)
frontendAPI.POST(picoclawAgentProfilePath, service.UpdateAgentProfile)
frontendAPI.GET(picoclawSessionsPath, service.ListSessions)
frontendAPI.GET(picoclawSessionByIDPath, service.GetSession)
frontendAPI.DELETE(picoclawSessionByIDPath, service.DeleteSession)
frontendAPI.GET(picoclawRuntimeStatusPath, service.GetRuntimeStatus)
frontendAPI.DELETE(picoclawRuntimeSessionPath, service.ReleaseRuntimeSession)
frontendAPI.POST(picoclawRuntimeInstallPath, service.InstallRuntime)
frontendAPI.POST(picoclawRuntimeUninstallPath, service.UninstallRuntime)
frontendAPI.POST(picoclawRuntimeStartPath, service.StartRuntime)
frontendAPI.POST(picoclawRuntimeStopPath, service.StopRuntime)
frontendAPI.GET(picoclawGatewayWSPath, service.ConnectGateway)
}

View File

@@ -36,7 +36,14 @@ func server(r *gin.Engine) {
storageRouter(r)
networkRouter(r)
hidRouter(r)
picoclawRouter(r)
wsRouter(r)
downloadRouter(r)
extensionsRouter(r)
}
func LoopbackHTTPAllowedPaths() []string {
paths := PicoclawLoopbackHTTPAllowedPaths()
paths = append(paths, HIDLoopbackHTTPAllowedPaths()...)
return paths
}

View File

@@ -2,8 +2,11 @@ package hid
import (
"errors"
"fmt"
"io"
"os"
"sync"
"syscall"
"time"
log "github.com/sirupsen/logrus"
@@ -23,6 +26,24 @@ const (
HID2 = "/dev/hidg2" // Touchpad (Absolute Mode)
)
const (
hidWriteTimeout = 50 * time.Millisecond
hidWriteRetryDelay = time.Millisecond
hidReopenTimeout = 2 * time.Second
hidReopenRetryDelay = 100 * time.Millisecond
)
type hidWriter interface {
Write([]byte) (int, error)
}
type hidDevice struct {
path string
mu *sync.Mutex
get func() *os.File
set func(*os.File)
}
var (
hid *Hid
hidOnce sync.Once
@@ -45,33 +66,168 @@ func (h *Hid) Unlock() {
h.mouseMutex.Unlock()
}
func (h *Hid) OpenNoLock() {
var err error
h.CloseNoLock()
h.g0, err = os.OpenFile(HID0, os.O_WRONLY, 0o666)
if err != nil {
log.Errorf("open %s failed: %s", HID0, err)
}
h.g1, err = os.OpenFile(HID1, os.O_WRONLY, 0o666)
if err != nil {
log.Errorf("open %s failed: %s", HID1, err)
}
h.g2, err = os.OpenFile(HID2, os.O_WRONLY, 0o666)
if err != nil {
log.Errorf("open %s failed: %s", HID2, err)
func (h *Hid) keyboardDevice(path string) hidDevice {
return hidDevice{
path: path,
mu: &h.kbMutex,
get: func() *os.File {
return h.g0
},
set: func(file *os.File) {
h.g0 = file
},
}
}
func (h *Hid) CloseNoLock() {
for _, file := range []*os.File{h.g0, h.g1, h.g2} {
if file != nil {
_ = file.Sync()
_ = file.Close()
func (h *Hid) relativeMouseDevice(path string) hidDevice {
return hidDevice{
path: path,
mu: &h.mouseMutex,
get: func() *os.File {
return h.g1
},
set: func(file *os.File) {
h.g1 = file
},
}
}
func (h *Hid) absoluteMouseDevice(path string) hidDevice {
return hidDevice{
path: path,
mu: &h.mouseMutex,
get: func() *os.File {
return h.g2
},
set: func(file *os.File) {
h.g2 = file
},
}
}
func (h *Hid) devices() []hidDevice {
return []hidDevice{
h.keyboardDevice(HID0),
h.relativeMouseDevice(HID1),
h.absoluteMouseDevice(HID2),
}
}
func (h *Hid) OpenNoLock() error {
h.CloseNoLock()
var errs []error
for _, device := range h.devices() {
if err := h.openDeviceNoLock(device); err != nil {
log.Errorf("open %s failed: %s", device.path, err)
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
func (h *Hid) OpenNoLockWithRetry(timeout, delay time.Duration) error {
return openNoLockWithRetry(h.OpenNoLock, timeout, delay)
}
func openNoLockWithRetry(open func() error, timeout, delay time.Duration) error {
if timeout <= 0 {
return open()
}
if delay <= 0 {
delay = hidReopenRetryDelay
}
deadline := time.Now().Add(timeout)
var lastErr error
for {
if err := open(); err != nil {
lastErr = err
} else {
return nil
}
remaining := time.Until(deadline)
if remaining <= 0 {
break
}
if remaining > delay {
remaining = delay
}
time.Sleep(remaining)
}
return fmt.Errorf("open HID devices within %s: %w", timeout, lastErr)
}
func (h *Hid) CloseNoLock() {
for _, device := range h.devices() {
h.closeDeviceNoLock(device)
}
}
func (h *Hid) openDeviceNoLock(device hidDevice) error {
if device.get() != nil {
return nil
}
file, err := os.OpenFile(device.path, os.O_WRONLY|syscall.O_NONBLOCK, 0o666)
if err != nil {
return fmt.Errorf("%s: %w", device.path, err)
}
device.set(file)
return nil
}
func (h *Hid) closeDeviceNoLock(device hidDevice) {
file := device.get()
if file == nil {
return
}
device.set(nil)
if err := file.Close(); err != nil {
log.Debugf("close %s failed: %s", device.path, err)
}
}
// writeWithTimeout bounds how long callers hold HID locks when writing to a
// nonblocking descriptor. EAGAIN means the host is not accepting HID reports
// yet, so retry until the caller's deadline expires.
func writeWithTimeout(writer hidWriter, data []byte, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for {
n, err := writer.Write(data)
if err == nil {
if n != len(data) {
return io.ErrShortWrite
}
return nil
}
if n != 0 {
return io.ErrShortWrite
}
if !isRetryableWriteError(err) {
return err
}
remaining := time.Until(deadline)
if timeout <= 0 || remaining <= 0 {
return os.ErrDeadlineExceeded
}
if remaining > hidWriteRetryDelay {
remaining = hidWriteRetryDelay
}
time.Sleep(remaining)
}
}
func isRetryableWriteError(err error) bool {
return errors.Is(err, syscall.EAGAIN) || errors.Is(err, syscall.EWOULDBLOCK)
}
func (h *Hid) Open() {
@@ -80,8 +236,6 @@ func (h *Hid) Open() {
h.mouseMutex.Lock()
defer h.mouseMutex.Unlock()
h.CloseNoLock()
h.OpenNoLock()
}
@@ -95,73 +249,55 @@ func (h *Hid) Close() {
}
func (h *Hid) WriteHid0(data []byte) {
deadline := time.Now().Add(8 * time.Millisecond)
h.kbMutex.Lock()
_ = h.g0.SetWriteDeadline(deadline)
_, err := h.g0.Write(data)
h.kbMutex.Unlock()
if err != nil {
switch {
case errors.Is(err, os.ErrClosed):
log.Errorf("hid already closed, reopen it...")
h.OpenNoLock()
case errors.Is(err, os.ErrDeadlineExceeded):
log.Debugf("write to %s timeout", HID0)
default:
log.Errorf("write to %s failed: %s", HID0, err)
}
return
}
log.Debugf("write to %s: %v", HID0, data)
h.writeHIDReport(h.keyboardDevice(HID0), data)
}
func (h *Hid) WriteHid1(data []byte) {
deadline := time.Now().Add(8 * time.Millisecond)
h.mouseMutex.Lock()
_ = h.g1.SetWriteDeadline(deadline)
_, err := h.g1.Write(data)
h.mouseMutex.Unlock()
if err != nil {
switch {
case errors.Is(err, os.ErrClosed):
log.Errorf("hid already closed, reopen it...")
h.OpenNoLock()
case errors.Is(err, os.ErrDeadlineExceeded):
log.Debugf("write to %s timeout", HID1)
default:
log.Errorf("write to %s failed: %s", HID1, err)
}
return
}
log.Debugf("write to %s: %v", HID1, data)
h.writeHIDReport(h.relativeMouseDevice(HID1), data)
}
func (h *Hid) WriteHid2(data []byte) {
deadline := time.Now().Add(8 * time.Millisecond)
h.writeHIDReport(h.absoluteMouseDevice(HID2), data)
}
h.mouseMutex.Lock()
_ = h.g2.SetWriteDeadline(deadline)
_, err := h.g2.Write(data)
h.mouseMutex.Unlock()
func (h *Hid) writeHIDReport(device hidDevice, data []byte) bool {
if err := h.writeHID(device, data); err != nil {
log.Errorf("write to %s failed: %s", device.path, err)
return false
}
return true
}
if err != nil {
switch {
case errors.Is(err, os.ErrClosed):
log.Errorf("hid already closed, reopen it...")
h.OpenNoLock()
case errors.Is(err, os.ErrDeadlineExceeded):
log.Debugf("write to %s timeout", HID2)
default:
log.Errorf("write to %s failed: %s", HID2, err)
}
return
func (h *Hid) writeHID(device hidDevice, data []byte) error {
device.mu.Lock()
defer device.mu.Unlock()
if err := h.openDeviceNoLock(device); err != nil {
return err
}
log.Debugf("write to %s: %v", HID2, data)
file := device.get()
if file == nil {
return fmt.Errorf("%s: hid handle is nil", device.path)
}
deadline := time.Now().Add(hidWriteTimeout)
if err := file.SetWriteDeadline(deadline); err != nil {
log.Debugf("set write deadline for %s failed: %s", device.path, err)
}
if err := writeWithTimeout(file, data, hidWriteTimeout); err != nil {
h.closeDeviceNoLock(device)
switch {
case errors.Is(err, os.ErrClosed):
return fmt.Errorf("hid already closed: %w", err)
case errors.Is(err, os.ErrDeadlineExceeded):
return fmt.Errorf("timeout after %s: %w", hidWriteTimeout, err)
default:
return err
}
}
log.Debugf("write to %s: %v", device.path, data)
return nil
}

View File

@@ -5,12 +5,46 @@ import (
)
func (h *Hid) Keyboard(queue <-chan []byte) {
h.keyboard(queue, HID0)
}
func (h *Hid) keyboard(queue <-chan []byte, path string) {
defer h.releaseKeyboard(path)
for event := range queue {
if len(event) != 8 {
log.Debugf("invalid keyboard event: %v", event)
continue
}
h.WriteHid0(event)
if !h.writeHIDReport(h.keyboardDevice(path), event) {
if dropped := drainHIDQueue(queue); dropped > 0 {
log.Debugf("dropped %d stale keyboard HID reports after write failure", dropped)
}
h.releaseKeyboard(path)
}
}
}
func (h *Hid) releaseKeyboard(path string) {
h.writeHIDReport(h.keyboardDevice(path), keyboardReleaseReport())
}
func keyboardReleaseReport() []byte {
return []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
}
func drainHIDQueue(queue <-chan []byte) int {
dropped := 0
for {
select {
case _, ok := <-queue:
if !ok {
return dropped
}
dropped++
default:
return dropped
}
}
}

View File

@@ -5,14 +5,67 @@ import (
)
func (h *Hid) Mouse(queue <-chan []byte) {
h.mouse(queue, HID1, HID2)
}
func (h *Hid) mouse(queue <-chan []byte, relativePath string, absolutePath string) {
defer h.releaseRelativeMouse(relativePath)
absoluteButtonsActive := false
var absoluteReleaseReport []byte
defer func() {
if absoluteButtonsActive {
h.releaseAbsoluteMouse(absolutePath, absoluteReleaseReport)
}
}()
for event := range queue {
switch len(event) {
case 4:
h.WriteHid1(event)
if !h.writeHIDReport(h.relativeMouseDevice(relativePath), event) {
if dropped := drainHIDQueue(queue); dropped > 0 {
log.Debugf("dropped %d stale mouse HID reports after relative write failure", dropped)
}
h.releaseRelativeMouse(relativePath)
}
case 6:
h.WriteHid2(event)
if !h.writeHIDReport(h.absoluteMouseDevice(absolutePath), event) {
if dropped := drainHIDQueue(queue); dropped > 0 {
log.Debugf("dropped %d stale mouse HID reports after absolute write failure", dropped)
}
if absoluteButtonsActive {
if h.releaseAbsoluteMouse(absolutePath, absoluteReleaseReport) {
absoluteButtonsActive = false
}
} else if event[0] != 0 {
h.releaseAbsoluteMouse(absolutePath, absoluteMouseReleaseReport(event))
}
continue
}
absoluteReleaseReport = absoluteMouseReleaseReport(event)
absoluteButtonsActive = event[0] != 0
default:
log.Debugf("invalid mouse event: %v", event)
}
}
}
func (h *Hid) releaseRelativeMouse(path string) {
h.writeHIDReport(h.relativeMouseDevice(path), relativeMouseReleaseReport())
}
func (h *Hid) releaseAbsoluteMouse(path string, report []byte) bool {
return h.writeHIDReport(h.absoluteMouseDevice(path), report)
}
func relativeMouseReleaseReport() []byte {
return []byte{0x00, 0x00, 0x00, 0x00}
}
func absoluteMouseReleaseReport(positionReport []byte) []byte {
report := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
if len(positionReport) >= 5 {
copy(report[1:5], positionReport[1:5])
}
return report
}

View File

@@ -83,6 +83,81 @@ func LangueSwitch(base map[rune]Char, lang string) map[rune]Char {
m['\u00B2'] = Char{0x40, 31} // ²
m['\u00B3'] = Char{0x40, 32} // ³
case "fr":
// French AZERTY layout
// Letters: a↔q swap, z↔w swap, m moved to physical ; position
m['a'] = Char{0, 20} // a is at physical Q key (HID 20)
m['A'] = Char{2, 20}
m['q'] = Char{0, 4} // q is at physical A key (HID 4)
m['Q'] = Char{2, 4}
m['z'] = Char{0, 26} // z is at physical W key (HID 26)
m['Z'] = Char{2, 26}
m['w'] = Char{0, 29} // w is at physical Z key (HID 29)
m['W'] = Char{2, 29}
m['m'] = Char{0, 51} // m is at physical ; key (HID 51)
m['M'] = Char{2, 51}
// Numbers require Shift on AZERTY
m['1'] = Char{2, 30}
m['2'] = Char{2, 31}
m['3'] = Char{2, 32}
m['4'] = Char{2, 33}
m['5'] = Char{2, 34}
m['6'] = Char{2, 35}
m['7'] = Char{2, 36}
m['8'] = Char{2, 37}
m['9'] = Char{2, 38}
m['0'] = Char{2, 39}
// Unshifted number row → French/special characters
m['&'] = Char{0, 30} // & at physical key 1
m['\u00E9'] = Char{0, 31} // é at physical key 2
m['"'] = Char{0, 32} // " at physical key 3
m['\''] = Char{0, 33} // ' at physical key 4
m['('] = Char{0, 34} // ( at physical key 5
m['-'] = Char{0, 35} // - at physical key 6
m['\u00E8'] = Char{0, 36} // è at physical key 7
m['_'] = Char{0, 37} // _ at physical key 8
m['\u00E7'] = Char{0, 38} // ç at physical key 9
m['\u00E0'] = Char{0, 39} // à at physical key 0
// Physical - key (HID 45) → ) on AZERTY
m[')'] = Char{0, 45} // ) at physical - key
m['\u00B0'] = Char{2, 45} // ° at shift+physical - key
// Letter-row bracket/special keys
m['^'] = Char{0, 47} // ^ (dead) at physical [ key (HID 47)
m['\u00A8'] = Char{2, 47} // ¨ at shift+[
m['$'] = Char{0, 48} // $ at physical ] key (HID 48)
m['\u00A3'] = Char{2, 48} // £ at shift+]
m['*'] = Char{0, 49} // * at physical \ key (HID 49)
m['\u00B5'] = Char{2, 49} // µ at shift+\
m['\u00F9'] = Char{0, 52} // ù at physical ' key (HID 52)
m['%'] = Char{2, 52} // % at shift+'
// Bottom row remappings
m[','] = Char{0, 16} // , at physical M key (HID 16)
m['?'] = Char{2, 16} // ? at shift+physical M
m[';'] = Char{0, 54} // ; at physical , key (HID 54)
m['.'] = Char{2, 54} // . at shift+physical ,
m[':'] = Char{0, 55} // : at physical . key (HID 55)
m['/'] = Char{2, 55} // / at shift+physical .
m['!'] = Char{0, 56} // ! at physical / key (HID 56)
m['\u00A7'] = Char{2, 56} // § at shift+physical /
// AltGr combinations
m['~'] = Char{0x40, 31} // AltGr+2
m['#'] = Char{0x40, 32} // AltGr+3
m['{'] = Char{0x40, 33} // AltGr+4
m['['] = Char{0x40, 34} // AltGr+5
m['|'] = Char{0x40, 35} // AltGr+6
m['`'] = Char{0x40, 36} // AltGr+7
m['\\'] = Char{0x40, 37} // AltGr+8
m[']'] = Char{0x40, 45} // AltGr+physical -
m['}'] = Char{0x40, 46} // AltGr+=
m['@'] = Char{0x40, 39} // AltGr+0
m['\u20AC'] = Char{0x40, 8} // € AltGr+E
}
return m
}
@@ -131,6 +206,10 @@ func copyMap(src map[rune]Char) map[rune]Char {
return dst
}
func GetCharMap(lang string) map[rune]Char {
return LangueSwitch(charMap, lang)
}
var charMap = map[rune]Char{
// Lowercase letters
'a': {0, 4}, 'b': {0, 5}, 'c': {0, 6}, 'd': {0, 7}, 'e': {0, 8},

View File

@@ -91,17 +91,7 @@ func (s *Service) SetHidMode(c *gin.Context) {
func (s *Service) ResetHid(c *gin.Context) {
var rsp proto.Response
h := GetHid()
h.Lock()
h.CloseNoLock()
defer func() {
h.OpenNoLock()
h.Unlock()
}()
command := fmt.Sprintf("%s restart_phy", USBDevScript)
err := exec.Command("sh", "-c", command).Run()
if err != nil {
if err := ResetUSBPHY(); err != nil {
log.Errorf("failed to reset hid: %v", err)
rsp.ErrRsp(c, -1, "failed to reset hid")
return
@@ -111,6 +101,37 @@ func (s *Service) ResetHid(c *gin.Context) {
log.Debugf("reset hid success")
}
func (s *Service) RecoverUSB(c *gin.Context) {
var rsp proto.Response
if err := ResetUSBPHY(); err != nil {
log.Errorf("failed to recover usb: %v", err)
rsp.ErrRsp(c, -1, "failed to recover usb")
return
}
rsp.OkRsp(c)
log.Debugf("recover usb success")
}
func ResetUSBPHY() error {
h := GetHid()
h.Lock()
h.CloseNoLock()
defer h.Unlock()
command := fmt.Sprintf("%s restart_phy", USBDevScript)
if err := exec.Command("sh", "-c", command).Run(); err != nil {
return fmt.Errorf("restart usb phy: %w", err)
}
if err := h.OpenNoLockWithRetry(hidReopenTimeout, hidReopenRetryDelay); err != nil {
return fmt.Errorf("reopen HID devices after usb phy reset: %w", err)
}
return nil
}
func copyModeFile(srcScript string) error {
// open the source file
srcFile, err := os.Open(srcScript)

View File

@@ -0,0 +1,642 @@
package network
import (
"bufio"
_ "embed"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"NanoKVM-Server/proto"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
const (
dnsModeManual = "manual"
dnsModeDHCP = "dhcp"
dnsConfigDir = "/etc/kvm/network"
dnsModeFile = "/etc/kvm/network/dns.mode"
dnsServersFile = "/etc/kvm/network/dns.servers"
bootResolvFile = "/boot/resolv.conf"
bootResolvBackup = "/boot/resolv.conf.manual.bak"
etcResolvFile = "/etc/resolv.conf"
dhcpResolvFile = "/etc/resolv.conf.dhcp"
tmpResolvFile = "/tmp/resolv.conf"
udhcpcHookFile = "/usr/share/udhcpc/default.script.d/99-nanokvm-dns"
maxDNSServers = 6
)
//go:embed scripts/99-nanokvm-dns
var udhcpcDNSHook string
type resolvConfig struct {
Servers []string
SearchDomains []string
}
func (s *Service) GetDNS(c *gin.Context) {
var rsp proto.Response
mode := currentDNSMode()
servers := readManualDNSServers()
if len(servers) == 0 {
servers, _ = parseResolvConf(bootResolvFile)
}
if len(servers) == 0 {
servers, _ = parseResolvConf(bootResolvBackup)
}
effective, _ := parseResolvConf(etcResolvFile)
dhcpConfig, _ := readDHCPResolvConfig(canFallbackEffectiveForDHCP())
dhcp := dhcpConfig.Servers
info := getDNSInfo()
rsp.OkRspWithData(c, &proto.GetDNSRsp{
Mode: mode,
Servers: servers,
Effective: effective,
DHCP: dhcp,
Info: info,
})
log.Debugf("get dns config: mode=%s servers=%v effective=%v dhcp=%v info=%+v", mode, servers, effective, dhcp, info)
}
func (s *Service) SetDNS(c *gin.Context) {
var req proto.SetDNSReq
var rsp proto.Response
if err := proto.ParseFormRequest(c, &req); err != nil {
rsp.ErrRsp(c, -1, "invalid arguments")
return
}
switch req.Mode {
case dnsModeManual:
if err := setManualDNS(req.Servers); err != nil {
log.Errorf("failed to set manual dns: %s", err)
rsp.ErrRsp(c, -2, err.Error())
return
}
case dnsModeDHCP:
if err := setDHCPDNS(); err != nil {
log.Errorf("failed to set dhcp dns: %s", err)
rsp.ErrRsp(c, -3, err.Error())
return
}
default:
rsp.ErrRsp(c, -1, "invalid dns mode")
return
}
_ = exec.Command("sync").Run()
rsp.OkRsp(c)
log.Debugf("set dns config: mode=%s servers=%v", req.Mode, req.Servers)
}
func setManualDNS(servers []string) error {
normalized, err := validateDNSServers(servers)
if err != nil {
return err
}
if len(normalized) == 0 {
return fmt.Errorf("dns servers are required")
}
if currentDNSMode() == dnsModeDHCP {
if err := refreshDHCPResolvCacheFromEffective(); err != nil {
log.Warnf("failed to refresh dhcp dns cache before switching to manual: %s", err)
}
}
if err := os.MkdirAll(dnsConfigDir, 0o755); err != nil {
return fmt.Errorf("failed to create dns config directory: %w", err)
}
if err := writeDNSMode(dnsModeManual); err != nil {
return err
}
if err := os.WriteFile(dnsServersFile, []byte(strings.Join(normalized, "\n")+"\n"), 0o644); err != nil {
return fmt.Errorf("failed to write dns servers: %w", err)
}
if err := renderResolvConf(bootResolvFile, normalized); err != nil {
return err
}
if err := renderResolvConf(etcResolvFile, normalized); err != nil {
return err
}
return installUDHCPCDNSHook()
}
func setDHCPDNS() error {
dhcpConfig, err := readDHCPResolvConfig(canFallbackEffectiveForDHCP())
if err != nil {
return fmt.Errorf("failed to read dhcp dns: %w", err)
}
if len(dhcpConfig.Servers) == 0 {
return fmt.Errorf("no dhcp dns is currently available")
}
if err := os.MkdirAll(dnsConfigDir, 0o755); err != nil {
return fmt.Errorf("failed to create dns config directory: %w", err)
}
if err := renderResolvConfig(dhcpResolvFile, dhcpConfig); err != nil {
return fmt.Errorf("failed to write dhcp dns cache: %w", err)
}
if err := writeDNSMode(dnsModeDHCP); err != nil {
return err
}
if err := preserveManualDNSServers(); err != nil {
return err
}
if err := backupAndRemoveBootResolv(); err != nil {
return err
}
if err := renderResolvConfig(etcResolvFile, dhcpConfig); err != nil {
return err
}
return installUDHCPCDNSHook()
}
func currentDNSMode() string {
mode := readRawDNSMode()
if mode == "" {
mode = defaultDNSMode()
}
return mode
}
func readDNSMode() string {
return readRawDNSMode()
}
func readRawDNSMode() string {
data, err := os.ReadFile(dnsModeFile)
if err != nil {
return ""
}
mode := strings.TrimSpace(string(data))
if mode != dnsModeManual && mode != dnsModeDHCP {
return ""
}
return mode
}
func canFallbackEffectiveForDHCP() bool {
mode := readRawDNSMode()
if mode == dnsModeDHCP {
return true
}
if mode == dnsModeManual {
return false
}
if _, err := os.Stat(bootResolvFile); os.IsNotExist(err) {
return true
}
return false
}
func defaultDNSMode() string {
if _, err := os.Stat(bootResolvFile); err == nil {
return dnsModeManual
}
return dnsModeDHCP
}
func writeDNSMode(mode string) error {
if err := os.WriteFile(dnsModeFile, []byte(mode+"\n"), 0o644); err != nil {
return fmt.Errorf("failed to write dns mode: %w", err)
}
return nil
}
func readManualDNSServers() []string {
servers, err := parsePlainDNSServers(dnsServersFile)
if err != nil {
return nil
}
return servers
}
func preserveManualDNSServers() error {
if len(readManualDNSServers()) > 0 {
return nil
}
servers, _ := parseResolvConf(bootResolvFile)
if len(servers) == 0 {
servers, _ = parseResolvConf(bootResolvBackup)
}
if len(servers) == 0 {
return nil
}
if err := os.WriteFile(dnsServersFile, []byte(strings.Join(servers, "\n")+"\n"), 0o644); err != nil {
return fmt.Errorf("failed to preserve manual dns servers: %w", err)
}
return nil
}
func parsePlainDNSServers(path string) ([]string, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
lines := strings.Split(string(data), "\n")
return normalizeDNSServers(lines)
}
func readDHCPResolvConfig(allowEffectiveFallback bool) (resolvConfig, error) {
config, err := parseResolvConfig(dhcpResolvFile)
if err == nil && len(config.Servers) > 0 {
return config, nil
}
tmpConfig, tmpErr := parseResolvConfig(tmpResolvFile)
if tmpErr == nil && len(tmpConfig.Servers) > 0 {
return tmpConfig, nil
}
if allowEffectiveFallback {
effective, effectiveErr := parseResolvConfig(etcResolvFile)
if effectiveErr == nil && len(effective.Servers) > 0 {
return effective, nil
}
}
if err != nil && !os.IsNotExist(err) {
return resolvConfig{}, err
}
if tmpErr != nil && !os.IsNotExist(tmpErr) {
return resolvConfig{}, tmpErr
}
return resolvConfig{}, nil
}
func refreshDHCPResolvCacheFromEffective() error {
config, err := parseResolvConfig(etcResolvFile)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if len(config.Servers) == 0 {
return nil
}
if len(config.SearchDomains) == 0 {
cached, _ := parseResolvConfig(dhcpResolvFile)
config.SearchDomains = cached.SearchDomains
}
return renderResolvConfig(dhcpResolvFile, config)
}
func parseResolvConf(path string) ([]string, error) {
config, err := parseResolvConfig(path)
if err != nil {
return nil, err
}
return config.Servers, nil
}
func parseResolvConfig(path string) (resolvConfig, error) {
file, err := os.Open(path)
if err != nil {
return resolvConfig{}, err
}
defer func() {
_ = file.Close()
}()
var servers []string
var searchDomains []string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := stripInlineComment(scanner.Text())
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
switch fields[0] {
case "nameserver":
servers = append(servers, fields[1])
case "search":
searchDomains = append(searchDomains, fields[1:]...)
case "domain":
searchDomains = append(searchDomains, fields[1])
}
}
if err := scanner.Err(); err != nil {
return resolvConfig{}, err
}
normalizedServers, err := normalizeDNSServers(servers)
if err != nil {
return resolvConfig{}, err
}
return resolvConfig{
Servers: normalizedServers,
SearchDomains: normalizeSearchDomains(searchDomains),
}, nil
}
func getDNSInfo() proto.DNSInfo {
ifaceName, gateway := getDefaultIPv4Route()
if ifaceName == "" {
ifaceName = getFallbackIPv4Interface()
}
info := proto.DNSInfo{
Interface: ifaceName,
Gateway: gateway,
}
dhcpConfig, _ := readDHCPResolvConfig(canFallbackEffectiveForDHCP())
info.SearchDomains = dhcpConfig.SearchDomains
if ifaceName == "" {
return info
}
iface, err := net.InterfaceByName(ifaceName)
if err != nil {
return info
}
info.Type = getDNSInterfaceType(iface.Name)
info.Address, info.SubnetMask = getIPv4AddressInfo(*iface)
return info
}
func getDefaultIPv4Route() (string, string) {
file, err := os.Open("/proc/net/route")
if err != nil {
return "", ""
}
defer func() {
_ = file.Close()
}()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) < 8 || fields[1] != "00000000" {
continue
}
return fields[0], parseRouteGateway(fields[2])
}
return "", ""
}
func parseRouteGateway(value string) string {
gateway, err := strconv.ParseUint(value, 16, 32)
if err != nil || gateway == 0 {
return ""
}
return net.IPv4(byte(gateway), byte(gateway>>8), byte(gateway>>16), byte(gateway>>24)).String()
}
func getFallbackIPv4Interface() string {
interfaces, err := net.Interfaces()
if err != nil {
return ""
}
for _, iface := range interfaces {
if iface.Flags&net.FlagUp == 0 {
continue
}
if getDNSInterfaceType(iface.Name) == "" {
continue
}
address, _ := getIPv4AddressInfo(iface)
if address != "" {
return iface.Name
}
}
return ""
}
func getDNSInterfaceType(name string) string {
if strings.HasPrefix(name, "eth") || strings.HasPrefix(name, "en") {
return "Wired"
}
if strings.HasPrefix(name, "wlan") || strings.HasPrefix(name, "wl") {
return "Wireless"
}
return ""
}
func getIPv4AddressInfo(iface net.Interface) (string, string) {
addrs, err := iface.Addrs()
if err != nil {
return "", ""
}
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)
if !ok {
continue
}
ip := ipNet.IP.To4()
if ip == nil {
continue
}
ones, _ := ipNet.Mask.Size()
return fmt.Sprintf("%s/%d", ip.String(), ones), net.IP(ipNet.Mask).String()
}
return "", ""
}
func renderResolvConf(path string, servers []string) error {
return renderResolvConfig(path, resolvConfig{Servers: servers})
}
func renderResolvConfig(path string, config resolvConfig) error {
normalized, err := normalizeDNSServers(config.Servers)
if err != nil {
return err
}
if len(normalized) == 0 {
return fmt.Errorf("dns servers are required")
}
var builder strings.Builder
searchDomains := normalizeSearchDomains(config.SearchDomains)
if len(searchDomains) > 0 {
builder.WriteString("search ")
builder.WriteString(strings.Join(searchDomains, " "))
builder.WriteString("\n")
}
for _, server := range normalized {
builder.WriteString("nameserver ")
builder.WriteString(server)
builder.WriteString("\n")
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("failed to create dns file directory: %w", err)
}
if err := os.WriteFile(path, []byte(builder.String()), 0o644); err != nil {
return fmt.Errorf("failed to write %s: %w", path, err)
}
return nil
}
func validateDNSServers(servers []string) ([]string, error) {
normalized, err := normalizeDNSServers(servers)
if err != nil {
return nil, err
}
if len(normalized) > maxDNSServers {
return nil, fmt.Errorf("too many dns servers")
}
return normalized, nil
}
func normalizeDNSServers(servers []string) ([]string, error) {
seen := make(map[string]struct{})
var normalized []string
for _, server := range servers {
server = normalizeDNSServer(server)
if server == "" {
continue
}
ip := net.ParseIP(server)
if ip == nil {
return nil, fmt.Errorf("invalid dns server: %s", server)
}
server = ip.String()
if _, exists := seen[server]; exists {
continue
}
seen[server] = struct{}{}
normalized = append(normalized, server)
}
return normalized, nil
}
func normalizeSearchDomains(domains []string) []string {
seen := make(map[string]struct{})
var normalized []string
for _, domain := range domains {
domain = strings.TrimSpace(domain)
if domain == "" {
continue
}
if _, exists := seen[domain]; exists {
continue
}
seen[domain] = struct{}{}
normalized = append(normalized, domain)
}
return normalized
}
func normalizeDNSServer(server string) string {
server = stripInlineComment(server)
server = strings.TrimSpace(server)
fields := strings.Fields(server)
if len(fields) == 0 {
return ""
}
return fields[0]
}
func stripInlineComment(value string) string {
if index := strings.Index(value, "#"); index >= 0 {
return value[:index]
}
return value
}
func backupAndRemoveBootResolv() error {
data, err := os.ReadFile(bootResolvFile)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("failed to read boot resolv: %w", err)
}
if err := os.WriteFile(bootResolvBackup, data, 0o644); err != nil {
return fmt.Errorf("failed to backup boot resolv: %w", err)
}
if err := os.Remove(bootResolvFile); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove boot resolv: %w", err)
}
return nil
}
func installUDHCPCDNSHook() error {
if err := os.MkdirAll(filepath.Dir(udhcpcHookFile), 0o755); err != nil {
return fmt.Errorf("failed to create udhcpc hook directory: %w", err)
}
if err := os.WriteFile(udhcpcHookFile, []byte(udhcpcDNSHook), 0o755); err != nil {
return fmt.Errorf("failed to install udhcpc dns hook: %w", err)
}
return nil
}

View File

@@ -0,0 +1,56 @@
#!/bin/sh
ACTION="$1"
CACHE="/etc/resolv.conf.dhcp"
ACTIVE="/etc/resolv.conf"
MODE="/etc/kvm/network/dns.mode"
BOOT="/boot/resolv.conf"
[ "$ACTION" = "bound" ] || [ "$ACTION" = "renew" ] || exit 0
[ -n "$dns" ] || exit 0
TMPFILE="$(mktemp)" || exit 1
write_search_domain() {
if [ -n "$search" ]; then
printf "search"
for domain_name in $search; do
printf " %s" "$domain_name"
done
printf "\n"
return
fi
if [ -n "$domain" ]; then
printf "domain %s\n" "$domain"
return
fi
for source in "$CACHE" "$ACTIVE"; do
[ -f "$source" ] || continue
while IFS= read -r line; do
line="${line%%#*}"
set -- $line
[ "$1" = "search" ] || [ "$1" = "domain" ] || continue
[ -n "$2" ] || continue
printf "%s\n" "$line"
return
done < "$source"
done
}
{
write_search_domain
for server in $dns; do
printf "nameserver %s\n" "$server"
done
} > "$TMPFILE"
cat "$TMPFILE" > "$CACHE"
MODE_VALUE="$(cat "$MODE" 2>/dev/null)"
if [ "$MODE_VALUE" = "dhcp" ] || { [ -z "$MODE_VALUE" ] && [ ! -f "$BOOT" ]; }; then
cat "$TMPFILE" > "$ACTIVE"
fi
rm -f "$TMPFILE"
exit 0

View File

@@ -1,6 +1,7 @@
package network
import (
"crypto/subtle"
"fmt"
"os"
"os/exec"
@@ -21,6 +22,7 @@ const (
WiFiConnect = "/kvmapp/kvm/wifi_try_connect"
WiFiStateFile = "/kvmapp/kvm/wifi_state"
WiFiScript = "/etc/init.d/S30wifi"
WiFiApPassFile = "/kvmapp/kvm/ap.pass"
)
func (s *Service) GetWifi(c *gin.Context) {
@@ -62,6 +64,15 @@ func (s *Service) ConnectWifiNoAuth(c *gin.Context) {
return
}
// Verify AP Password
apKey := c.GetHeader("X-AP-Key")
expectedPass := getApPassword()
if apKey == "" || expectedPass == "" || subtle.ConstantTimeCompare([]byte(apKey), []byte(expectedPass)) != 1 {
time.Sleep(2 * time.Second)
rsp.ErrRsp(c, -4, "unauthorized")
return
}
if err := proto.ParseFormRequest(c, &req); err != nil {
time.Sleep(1 * time.Second)
rsp.ErrRsp(c, -2, "invalid parameters")
@@ -79,6 +90,26 @@ func (s *Service) ConnectWifiNoAuth(c *gin.Context) {
log.Debugf("set wifi ap mode successfully")
}
func (s *Service) VerifyApLogin(c *gin.Context) {
var rsp proto.Response
if !isSupported() || !isAPMode() {
time.Sleep(2 * time.Second)
rsp.ErrRsp(c, -1, "invalid mode")
return
}
apKey := c.GetHeader("X-AP-Key")
expectedPass := getApPassword()
if apKey == "" || expectedPass == "" || subtle.ConstantTimeCompare([]byte(apKey), []byte(expectedPass)) != 1 {
time.Sleep(2 * time.Second)
rsp.ErrRsp(c, -4, "unauthorized")
return
}
rsp.OkRsp(c)
}
func (s *Service) ConnectWifi(c *gin.Context) {
var req proto.ConnectWifiReq
var rsp proto.Response
@@ -111,7 +142,6 @@ func (s *Service) ConnectWifi(c *gin.Context) {
}
rsp.ErrRsp(c, -3, "failed to get wifi status")
return
}
func (s *Service) DisconnectWifi(c *gin.Context) {
@@ -182,3 +212,12 @@ func getWiFiSsid() string {
return strings.ReplaceAll(string(ssidByte), "\n", "")
}
func getApPassword() string {
passByte, err := os.ReadFile(WiFiApPassFile)
if err != nil {
return ""
}
return strings.ReplaceAll(string(passByte), "\n", "")
}

View File

@@ -1,6 +1,7 @@
package network
import (
"errors"
"fmt"
"os"
"os/exec"
@@ -33,8 +34,7 @@ func (s *Service) WakeOnLAN(c *gin.Context) {
return
}
command := fmt.Sprintf("ether-wake -b %s", mac)
cmd := exec.Command("sh", "-c", command)
cmd := exec.Command("ether-wake", "-b", mac)
output, err := cmd.CombinedOutput()
if err != nil {
@@ -43,7 +43,7 @@ func (s *Service) WakeOnLAN(c *gin.Context) {
return
}
go saveMac(mac)
saveMac(mac)
rsp.OkRsp(c)
log.Debugf("wake on lan: %s", mac)
@@ -52,14 +52,19 @@ func (s *Service) WakeOnLAN(c *gin.Context) {
func (s *Service) GetMac(c *gin.Context) {
var rsp proto.Response
content, err := os.ReadFile(WolMacFile)
macs, err := readWolMacs()
if err != nil {
if errors.Is(err, os.ErrNotExist) {
rsp.OkRspWithData(c, &proto.GetMacRsp{Macs: []string{}})
return
}
rsp.ErrRsp(c, -2, "open file error")
return
}
data := &proto.GetMacRsp{
Macs: strings.Split(string(content), "\n"),
Macs: macs,
}
rsp.OkRspWithData(c, data)
@@ -74,36 +79,54 @@ func (s *Service) SetMacName(c *gin.Context) {
return
}
content, err := os.ReadFile(WolMacFile)
mac, err := parseMAC(req.Mac)
if err != nil {
rsp.ErrRsp(c, -2, "invalid MAC address")
return
}
name := sanitizeWolMacName(req.Name)
if name == "" {
rsp.ErrRsp(c, -1, "invalid arguments")
return
}
macs, err := readWolMacs()
if err != nil {
log.Errorf("failed to open %s: %s", WolMacFile, err)
rsp.ErrRsp(c, -2, "read failed")
return
}
macs := strings.Split(string(content), "\n")
var newLines []string
macFound := false
for _, line := range macs {
parts := strings.Split(line, " ")
if req.Mac != parts[0] {
newLines = append(newLines, line)
itemMac, itemName, ok := splitWolMacLine(line)
if !ok {
continue
}
newLines = append(newLines, parts[0]+" "+req.Name)
normalizedMac, err := parseMAC(itemMac)
if err != nil {
newLines = append(newLines, formatWolMacLine(itemMac, itemName))
continue
}
if mac != normalizedMac {
newLines = append(newLines, formatWolMacLine(normalizedMac, itemName))
continue
}
newLines = append(newLines, formatWolMacLine(normalizedMac, name))
macFound = true
}
if !macFound {
log.Errorf("failed to found mac %s: %s", req.Mac, err)
log.Errorf("failed to find mac %s", req.Mac)
rsp.ErrRsp(c, -3, "write failed")
return
}
data := strings.Join(newLines, "\n")
err = os.WriteFile(WolMacFile, []byte(data), 0o644)
if err != nil {
if err = writeWolMacs(newLines); err != nil {
log.Errorf("failed to write %s: %s", WolMacFile, err)
rsp.ErrRsp(c, -3, "write failed")
return
@@ -122,26 +145,37 @@ func (s *Service) DeleteMac(c *gin.Context) {
return
}
content, err := os.ReadFile(WolMacFile)
mac, err := parseMAC(req.Mac)
if err != nil {
rsp.ErrRsp(c, -2, "invalid MAC address")
return
}
macs, err := readWolMacs()
if err != nil {
log.Errorf("failed to open %s: %s", WolMacFile, err)
rsp.ErrRsp(c, -2, "read failed")
return
}
macs := strings.Split(string(content), "\n")
var newMacs []string
for _, mac := range macs {
parts := strings.Split(mac, " ")
if req.Mac != parts[0] {
newMacs = append(newMacs, mac)
for _, line := range macs {
itemMac, itemName, ok := splitWolMacLine(line)
if !ok {
continue
}
normalizedMac, err := parseMAC(itemMac)
if err != nil {
newMacs = append(newMacs, formatWolMacLine(itemMac, itemName))
continue
}
if mac != normalizedMac {
newMacs = append(newMacs, formatWolMacLine(normalizedMac, itemName))
}
}
data := strings.Join(newMacs, "\n")
err = os.WriteFile(WolMacFile, []byte(data), 0o644)
if err != nil {
if err = writeWolMacs(newMacs); err != nil {
log.Errorf("failed to write %s: %s", WolMacFile, err)
rsp.ErrRsp(c, -3, "write failed")
return
@@ -182,7 +216,7 @@ func saveMac(mac string) {
return
}
err := os.MkdirAll(filepath.Dir(WolMacFile), 0o644)
err := os.MkdirAll(filepath.Dir(WolMacFile), 0o755)
if err != nil {
log.Errorf("failed to create dir: %s", err)
return
@@ -206,18 +240,85 @@ func saveMac(mac string) {
}
func isMacExist(mac string) bool {
content, err := os.ReadFile(WolMacFile)
macs, err := readWolMacs()
if err != nil {
return false
}
macs := strings.Split(string(content), "\n")
for _, item := range macs {
parts := strings.Split(item, " ")
if mac == parts[0] {
itemMac, _, ok := splitWolMacLine(item)
if !ok {
continue
}
normalizedMac, err := parseMAC(itemMac)
if err != nil {
continue
}
if mac == normalizedMac {
return true
}
}
return false
}
func readWolMacs() ([]string, error) {
content, err := os.ReadFile(WolMacFile)
if err != nil {
return nil, err
}
lines := strings.Split(string(content), "\n")
macs := make([]string, 0, len(lines))
for _, line := range lines {
mac, name, ok := splitWolMacLine(line)
if !ok {
continue
}
macs = append(macs, formatWolMacLine(mac, name))
}
return macs, nil
}
func writeWolMacs(macs []string) error {
data := ""
if len(macs) > 0 {
data = strings.Join(macs, "\n") + "\n"
}
return os.WriteFile(WolMacFile, []byte(data), 0o644)
}
func splitWolMacLine(line string) (string, string, bool) {
line = strings.TrimSpace(line)
if line == "" {
return "", "", false
}
fields := strings.Fields(line)
if len(fields) == 0 {
return "", "", false
}
mac := fields[0]
if len(line) == len(mac) {
return mac, "", true
}
name := line[len(mac):]
return mac, strings.TrimSpace(name), true
}
func formatWolMacLine(mac string, name string) string {
if name == "" {
return mac
}
return mac + " " + name
}
func sanitizeWolMacName(name string) string {
return strings.Join(strings.Fields(name), " ")
}

View File

@@ -0,0 +1,316 @@
package picoclaw
import (
"bytes"
"encoding/json"
"math"
"strings"
"time"
"NanoKVM-Server/service/hid"
"github.com/gin-gonic/gin"
)
const (
defaultClickHold = 40 * time.Millisecond
defaultKeyDelay = 30 * time.Millisecond
defaultDragSteps = 10
defaultScrollStep = 20 * time.Millisecond
)
func (s *Service) Actions(c *gin.Context) {
sessionID, sessionErr := s.requireSessionID(c)
if sessionErr != nil {
writePicoclawError(c, sessionErr)
return
}
releaseAfter, lockErr := s.lock.AcquireTemporary(sessionID)
if lockErr != nil {
writePicoclawError(c, lockErr)
return
}
if releaseAfter {
defer s.lock.Release(sessionID)
}
actions, actionErr := normalizeActions(c)
if actionErr != nil {
writePicoclawError(c, actionErr)
return
}
result, execErr := s.executeActions(sessionID, actions)
if execErr != nil {
writePicoclawError(c, execErr)
return
}
writeSuccess(c, result)
}
func normalizeActions(c *gin.Context) ([]Action, *PicoclawError) {
body := bytes.NewBuffer(nil)
if _, err := body.ReadFrom(c.Request.Body); err != nil {
return nil, newPicoclawError(CodeInvalidAction, "failed to read action payload")
}
raw := body.Bytes()
if len(raw) == 0 {
return nil, newPicoclawError(CodeInvalidAction, "empty action payload")
}
var batch ActionBatch
if err := json.Unmarshal(raw, &batch); err == nil && len(batch.Actions) > 0 {
return batch.Actions, nil
}
var action Action
if err := json.Unmarshal(raw, &action); err != nil || action.Action == "" {
return nil, newPicoclawError(CodeInvalidAction, "invalid action payload")
}
return []Action{action}, nil
}
func (s *Service) executeActions(sessionID string, actions []Action) (result ActionResult, err *PicoclawError) {
startedAt := time.Now()
if len(actions) == 0 {
return ActionResult{}, newPicoclawError(CodeInvalidAction, "empty actions")
}
defer func() {
if err != nil {
s.releaseAllHIDState()
}
}()
totalWrites := 0
for idx, action := range actions {
if lockErr := s.lock.Ensure(sessionID); lockErr != nil {
lockErr.Index = &idx
return ActionResult{}, lockErr
}
writes, execErr := s.executeAction(action)
if execErr != nil {
execErr.Index = &idx
return ActionResult{}, execErr
}
totalWrites += writes
}
result = ActionResult{
Action: actions[0].Action,
DurationMs: time.Since(startedAt).Milliseconds(),
HIDWrites: totalWrites,
ExecutedActions: len(actions),
}
if len(actions) > 1 {
result.Action = "batch"
}
return result, nil
}
func (s *Service) executeAction(action Action) (int, *PicoclawError) {
switch strings.ToLower(strings.TrimSpace(action.Action)) {
case "click":
x, y, err := normalizedPoint(action.X, action.Y)
if err != nil {
return 0, err
}
button, err := mouseButton(action.Button)
if err != nil {
return 0, err
}
writes := 0
writes += s.sendMouseMoveWithButton(x, y, 0x00, 0)
writes += s.sendMousePress(x, y, button)
time.Sleep(defaultClickHold)
writes += s.sendMouseRelease(x, y)
return writes, nil
case "move":
x, y, err := normalizedPoint(action.X, action.Y)
if err != nil {
return 0, err
}
return s.sendMouseMoveWithButton(x, y, 0x00, 0), nil
case "wait":
if action.DurationMs < 0 {
return 0, newPicoclawError(CodeInvalidAction, "wait duration must be >= 0")
}
time.Sleep(time.Duration(action.DurationMs) * time.Millisecond)
return 0, nil
case "drag":
fromX, fromY, err := normalizedNestedPoint(action.From)
if err != nil {
return 0, err
}
toX, toY, err := normalizedNestedPoint(action.To)
if err != nil {
return 0, err
}
button, err := mouseButton(action.Button)
if err != nil {
return 0, err
}
writes := 0
writes += s.sendMouseMoveWithButton(fromX, fromY, 0x00, 0)
writes += s.sendMousePress(fromX, fromY, button)
for step := 1; step <= defaultDragSteps; step++ {
ratio := float64(step) / float64(defaultDragSteps)
x := fromX + (toX-fromX)*ratio
y := fromY + (toY-fromY)*ratio
writes += s.sendMouseMoveWithButton(x, y, button, 0)
}
writes += s.sendMouseRelease(toX, toY)
return writes, nil
case "scroll":
x, y := 0.5, 0.5
if action.X != nil || action.Y != nil {
var err *PicoclawError
x, y, err = normalizedPoint(action.X, action.Y)
if err != nil {
return 0, err
}
}
amount := action.Amount
if amount == 0 {
amount = 1
}
if amount < 0 {
return 0, newPicoclawError(CodeInvalidAction, "scroll amount must be > 0")
}
wheel := 1
switch strings.ToLower(strings.TrimSpace(action.Direction)) {
case "", "up":
wheel = 1
case "down":
wheel = -1
default:
return 0, newPicoclawError(CodeInvalidAction, "invalid scroll direction")
}
writes := 0
for range amount {
writes += s.sendMouseMoveWithButton(x, y, 0x00, wheel)
writes += s.sendMouseMoveWithButton(x, y, 0x00, 0)
time.Sleep(defaultScrollStep)
}
return writes, nil
case "type":
if action.Text == "" {
return 0, newPicoclawError(CodeInvalidAction, "type requires text")
}
charMap := hid.GetCharMap("")
writes := 0
for _, char := range action.Text {
key, ok := charMap[char]
if !ok {
return 0, newPicoclawError(CodeInvalidAction, "unsupported character in type action")
}
writes += s.sendKeyboardReport([]byte{byte(key.Modifiers), 0x00, byte(key.Code), 0x00, 0x00, 0x00, 0x00, 0x00})
writes += s.sendKeyboardReport([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00})
time.Sleep(defaultKeyDelay)
}
return writes, nil
case "hotkey":
report, err := buildHotkeyReport([]string(action.Keys))
if err != nil {
return 0, err
}
writes := 0
writes += s.sendKeyboardReport(report)
time.Sleep(defaultClickHold)
writes += s.sendKeyboardReport([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00})
return writes, nil
}
return 0, newPicoclawError(CodeInvalidAction, "unknown or invalid action")
}
func toAbsoluteHidCoord(normalized float64) uint16 {
if normalized < 0 {
normalized = 0
}
if normalized > 1 {
normalized = 1
}
return uint16(math.Floor(0x7FFF*normalized)) + 1
}
func (s *Service) sendMousePress(x float64, y float64, button byte) int {
return s.sendMouseMoveWithButton(x, y, button, 0)
}
func (s *Service) sendMouseRelease(x float64, y float64) int {
return s.sendMouseMoveWithButton(x, y, 0x00, 0)
}
func (s *Service) sendMouseMoveWithButton(x float64, y float64, buttons byte, wheel int) int {
absoluteX := toAbsoluteHidCoord(x)
absoluteY := toAbsoluteHidCoord(y)
report := []byte{
buttons,
byte(absoluteX & 0xff),
byte(absoluteX >> 8),
byte(absoluteY & 0xff),
byte(absoluteY >> 8),
byte(int8(wheel)),
}
s.hid.WriteHid2(report)
return 1
}
func (s *Service) sendKeyboardReport(report []byte) int {
s.hid.WriteHid0(report)
return 1
}
func normalizedPoint(x *float64, y *float64) (float64, float64, *PicoclawError) {
if x == nil || y == nil {
return 0, 0, newPicoclawError(CodeInvalidAction, "action requires x and y")
}
if *x < 0 || *x > 1 || *y < 0 || *y > 1 {
return 0, 0, newPicoclawError(CodeInvalidAction, "coordinates must be within [0,1]")
}
return *x, *y, nil
}
func normalizedNestedPoint(point *Point) (float64, float64, *PicoclawError) {
if point == nil {
return 0, 0, newPicoclawError(CodeInvalidAction, "action requires point coordinates")
}
return normalizedPoint(point.X, point.Y)
}
func mouseButton(button string) (byte, *PicoclawError) {
switch strings.ToLower(strings.TrimSpace(button)) {
case "", "left":
return 1 << 0, nil
case "right":
return 1 << 1, nil
case "middle":
return 1 << 2, nil
case "back":
return 1 << 3, nil
case "forward":
return 1 << 4, nil
default:
return 0, newPicoclawError(CodeInvalidAction, "invalid mouse button")
}
}

View File

@@ -0,0 +1,147 @@
package picoclaw
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
const (
agentProfileDefault = "default"
agentProfileKVM = "kvm"
agentProfileFile = "AGENT.md"
)
type AgentProfileUpdateRequest struct {
Profile string `json:"profile"`
}
func resolveAgentProfileSourcePath(profile string) (string, error) {
fileName := agentProfileFile
switch profile {
case agentProfileDefault:
fileName = "AGENT.md"
case agentProfileKVM:
fileName = "AGENT_KVM.md"
default:
return "", fmt.Errorf("unsupported agent profile: %s", profile)
}
candidates := make([]string, 0, 4)
if wd, err := os.Getwd(); err == nil {
candidates = append(candidates,
filepath.Join(wd, "picoclaw", fileName),
filepath.Join(wd, "docs", fileName),
)
}
candidates = append(candidates,
filepath.Join("/kvmapp", "picoclaw", fileName),
filepath.Join(filepath.Dir(os.Args[0]), "..", "picoclaw", fileName),
)
for _, candidate := range candidates {
if _, err := os.Stat(candidate); err == nil {
return candidate, nil
}
}
return "", fmt.Errorf("agent profile source not found: %s", fileName)
}
func detectPicoclawAgentProfile() string {
workspacePath, err := resolvePicoclawWorkspacePath()
if err != nil {
return agentProfileKVM
}
targetPath := filepath.Join(workspacePath, agentProfileFile)
targetContent, err := os.ReadFile(targetPath)
if err != nil {
return agentProfileKVM
}
switch parseAgentProfileName(string(targetContent)) {
case "pico":
return agentProfileDefault
case "pico-kvm":
return agentProfileKVM
default:
return agentProfileKVM
}
}
func parseAgentProfileName(content string) string {
scanner := bufio.NewScanner(strings.NewReader(content))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if !strings.HasPrefix(line, "name:") {
continue
}
return strings.TrimSpace(strings.TrimPrefix(line, "name:"))
}
return ""
}
func applyPicoclawAgentProfile(profile string) error {
sourcePath, err := resolveAgentProfileSourcePath(profile)
if err != nil {
return err
}
workspacePath, err := resolvePicoclawWorkspacePath()
if err != nil {
return err
}
if err := os.MkdirAll(workspacePath, 0o755); err != nil {
return fmt.Errorf("failed to create picoclaw workspace: %w", err)
}
sourceContent, err := os.ReadFile(sourcePath)
if err != nil {
return fmt.Errorf("failed to read agent profile source: %w", err)
}
targetPath := filepath.Join(workspacePath, agentProfileFile)
if err := os.WriteFile(targetPath, sourceContent, 0o644); err != nil {
return fmt.Errorf("failed to write picoclaw AGENT.md: %w", err)
}
return nil
}
func withAgentProfile(status RuntimeStatus) RuntimeStatus {
status.AgentProfile = detectPicoclawAgentProfile()
return status
}
func (s *Service) UpdateAgentProfile(c *gin.Context) {
var req AgentProfileUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
writePicoclawError(c, newPicoclawError(CodeInvalidAction, "invalid agent profile payload"))
return
}
profile := strings.TrimSpace(req.Profile)
if profile != agentProfileDefault && profile != agentProfileKVM {
writePicoclawError(c, newPicoclawError(CodeInvalidAction, "invalid agent profile"))
return
}
if err := applyPicoclawAgentProfile(profile); err != nil {
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, err.Error()))
return
}
_ = s.syncConfigFromPicoclaw()
_ = s.ensureRuntimeReady()
writeSuccess(c, gin.H{
"profile": profile,
"status": withAgentProfile(s.runtime.Get()),
})
}

View File

@@ -0,0 +1,184 @@
package picoclaw
import (
"fmt"
"os"
"strings"
"time"
)
const (
defaultPicoclawGatewayHost = "127.0.0.1"
defaultPicoclawGatewayPort = 18790
picoclawGatewayPath = "/pico/ws"
)
func defaultConfig() Config {
cfg := Config{
GatewayURL: "ws://127.0.0.1:18790/pico/ws",
ConnectTimeoutMs: 10000,
ReadTimeoutMs: 60000,
WriteTimeoutMs: 10000,
PingIntervalMs: 30000,
MaxMessageBytes: 1024 * 1024,
AllowTokenQuery: false,
}
settings, err := loadPicoclawGatewaySettings()
if err != nil {
return cfg
}
cfg.GatewayURL = settings.GatewayURL
cfg.Token = settings.Token
cfg.AllowTokenQuery = settings.AllowTokenQuery
if settings.PingIntervalMs > 0 {
cfg.PingIntervalMs = settings.PingIntervalMs
}
if settings.ReadTimeoutMs > 0 {
cfg.ReadTimeoutMs = settings.ReadTimeoutMs
}
return cfg
}
func (s *Service) syncConfigFromPicoclaw() *PicoclawError {
installed, installedKnown := picoclawInstalledState()
if patchErr := preparePicoclawConfigForRead(); patchErr != nil {
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Status = "config_error"
status.ConfigError = patchErr.Error()
status.LastError = patchErr.Error()
if installedKnown {
status.Installed = installed
}
status.CheckedAt = time.Now()
})
return newPicoclawError(CodeRuntimeUnavailable, patchErr.Error())
}
settings, err := loadPicoclawGatewaySettings()
if err != nil {
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Status = "config_error"
status.ConfigError = err.Error()
status.LastError = err.Error()
if installedKnown {
status.Installed = installed
}
status.CheckedAt = time.Now()
})
return newPicoclawError(CodeRuntimeUnavailable, err.Error())
}
cfg := s.config.Get()
cfg.GatewayURL = settings.GatewayURL
cfg.Token = settings.Token
cfg.AllowTokenQuery = settings.AllowTokenQuery
if settings.PingIntervalMs > 0 {
cfg.PingIntervalMs = settings.PingIntervalMs
}
if settings.ReadTimeoutMs > 0 {
cfg.ReadTimeoutMs = settings.ReadTimeoutMs
}
s.config.Set(cfg)
s.runtime.Update(func(status *RuntimeStatus) {
status.Installed = true
status.ModelConfigured = settings.ModelConfigured
if settings.ModelConfigured {
status.ModelName = settings.ModelName
} else {
status.ModelName = settings.TargetModelName
}
status.ConfigError = ""
if status.Status == "config_error" && status.Ready {
status.Status = "ready"
}
status.CheckedAt = time.Now()
})
return nil
}
type picoclawGatewaySettings struct {
GatewayURL string
Token string
AllowTokenQuery bool
PingIntervalMs int
ReadTimeoutMs int
ModelConfigured bool
ModelName string
TargetModelName string
}
func loadPicoclawGatewaySettings() (picoclawGatewaySettings, error) {
doc, err := loadPicoclawConfigDocument()
if err != nil {
return picoclawGatewaySettings{}, err
}
if err := ensurePicoclawPicoChannelEnabled(doc); err != nil {
return picoclawGatewaySettings{}, err
}
cfg := doc.config
host := cfg.Gateway.Host
if host == "" || host == "0.0.0.0" {
host = defaultPicoclawGatewayHost
}
port := cfg.Gateway.Port
if port <= 0 {
port = defaultPicoclawGatewayPort
}
picoSettings := picoclawPicoSettingsV3{}
if pico, ok := cfg.Channels["pico"]; ok {
picoSettings = pico.Settings
}
settings := picoclawGatewaySettings{
GatewayURL: fmt.Sprintf("ws://%s:%d%s", host, port, picoclawGatewayPath),
Token: doc.resolvedGatewayToken(),
AllowTokenQuery: picoSettings.AllowTokenQuery,
}
settings.TargetModelName = resolvePicoclawTargetModelName(cfg)
if isPicoclawModelConfigured(cfg, doc.security, settings.TargetModelName) {
settings.ModelConfigured = true
settings.ModelName = settings.TargetModelName
}
if picoSettings.PingInterval > 0 {
settings.PingIntervalMs = picoSettings.PingInterval * 1000
}
if picoSettings.ReadTimeout > 0 {
settings.ReadTimeoutMs = picoSettings.ReadTimeout * 1000
}
return settings, nil
}
func resolvePicoclawTargetModelName(cfg picoclawConfigFile) string {
return strings.TrimSpace(cfg.Agents.Defaults.ModelName)
}
func preparePicoclawConfigForRead() error {
configPath, err := resolvePicoclawConfigPath()
if err != nil {
return err
}
if _, err := os.Stat(configPath); err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("failed to stat picoclaw config: %w", err)
}
return nil
}

View File

@@ -0,0 +1,215 @@
package picoclaw
import (
"bytes"
"encoding/json"
"fmt"
"os"
"os/user"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
type picoclawConfigFile struct {
Agents struct {
Defaults struct {
ModelName string `json:"model_name"`
Model string `json:"model,omitempty"`
Workspace string `json:"workspace,omitempty"`
} `json:"defaults"`
} `json:"agents"`
Gateway struct {
Host string `json:"host"`
Port int `json:"port"`
} `json:"gateway"`
ModelList []struct {
ModelName string `json:"model_name"`
Model string `json:"model"`
APIBase string `json:"api_base"`
APIKey string `json:"api_key"`
APIKeys []string `json:"api_keys,omitempty"`
} `json:"model_list"`
Channels map[string]picoclawChannelEntry `json:"channel_list"`
}
type picoclawChannelEntry struct {
Type string `json:"type"`
Enabled bool `json:"enabled"`
Settings picoclawPicoSettingsV3 `json:"settings"`
}
type picoclawPicoSettingsV3 struct {
Token string `json:"token,omitempty"`
AllowTokenQuery bool `json:"allow_token_query,omitempty"`
PingInterval int `json:"ping_interval,omitempty"`
ReadTimeout int `json:"read_timeout,omitempty"`
WriteTimeout int `json:"write_timeout,omitempty"`
MaxConnections int `json:"max_connections,omitempty"`
}
type picoclawConfigDocument struct {
configPath string
securityPath string
config picoclawConfigFile
raw map[string]any
security picoclawSecurityConfig
}
func loadPicoclawConfigDocument() (*picoclawConfigDocument, error) {
configPath, err := resolvePicoclawConfigPath()
if err != nil {
return nil, err
}
data, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("failed to read picoclaw config: %w", err)
}
var cfg picoclawConfigFile
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("failed to parse picoclaw config: %w", err)
}
var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("failed to parse picoclaw config for update: %w", err)
}
securityPath := resolvePicoclawSecurityPath(configPath)
security, err := loadPicoclawSecurityConfig(securityPath)
if err != nil {
return nil, err
}
return &picoclawConfigDocument{
configPath: configPath,
securityPath: securityPath,
config: cfg,
raw: raw,
security: security,
}, nil
}
func resolvePicoclawConfigPath() (string, error) {
home := os.Getenv("PICOCLAW_HOME")
if home == "" {
currentUser, err := user.Current()
if err != nil {
return "", fmt.Errorf("failed to resolve PICOCLAW_HOME: %w", err)
}
home = filepath.Join(currentUser.HomeDir, ".picoclaw")
}
return filepath.Join(home, "config.json"), nil
}
func expandPicoclawPath(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
if strings.HasPrefix(path, "~/") || path == "~" {
if home, err := os.UserHomeDir(); err == nil {
if path == "~" {
return home
}
return filepath.Join(home, strings.TrimPrefix(path, "~/"))
}
}
return path
}
func resolvePicoclawSecurityPath(configPath string) string {
return filepath.Join(filepath.Dir(configPath), ".security.yml")
}
func (d *picoclawConfigDocument) saveConfig() error {
updated, err := json.MarshalIndent(d.raw, "", " ")
if err != nil {
return fmt.Errorf("failed to encode updated picoclaw config: %w", err)
}
if err := os.WriteFile(d.configPath, updated, 0o600); err != nil {
return fmt.Errorf("failed to write picoclaw config: %w", err)
}
return nil
}
func (d *picoclawConfigDocument) saveSecurity() error {
var buf bytes.Buffer
encoder := yaml.NewEncoder(&buf)
encoder.SetIndent(2)
if err := encoder.Encode(d.security); err != nil {
return fmt.Errorf("failed to encode picoclaw security config: %w", err)
}
if err := encoder.Close(); err != nil {
return fmt.Errorf("failed to finalize picoclaw security config: %w", err)
}
if err := os.WriteFile(d.securityPath, buf.Bytes(), 0o600); err != nil {
return fmt.Errorf("failed to write picoclaw security config: %w", err)
}
return nil
}
type picoclawSecurityConfig struct {
ModelList map[string]picoclawModelSecurityEntry `yaml:"model_list,omitempty"`
ChannelList map[string]picoclawChannelSecurityEntry `yaml:"channel_list,omitempty"`
}
type picoclawModelSecurityEntry struct {
APIKeys []string `yaml:"api_keys,omitempty"`
}
type picoclawChannelSecurityEntry struct {
Token string `yaml:"token,omitempty"`
Settings *picoclawChannelSecuritySettings `yaml:"settings,omitempty"`
}
type picoclawChannelSecuritySettings struct {
Token string `yaml:"token,omitempty"`
}
func (d *picoclawConfigDocument) resolvedPicoToken() string {
if d == nil {
return ""
}
// Check security config (channel_list.pico with nested settings)
if entry, ok := d.security.ChannelList["pico"]; ok {
if entry.Settings != nil && entry.Settings.Token != "" {
return entry.Settings.Token
}
if entry.Token != "" {
return entry.Token
}
}
// Fall back to config.json channel_list.pico.settings.token
if pico, ok := d.config.Channels["pico"]; ok {
return pico.Settings.Token
}
return ""
}
func (d *picoclawConfigDocument) resolvedGatewayToken() string {
return strings.TrimSpace(d.resolvedPicoToken())
}
func loadPicoclawSecurityConfig(securityPath string) (picoclawSecurityConfig, error) {
data, err := os.ReadFile(securityPath)
if err != nil {
if os.IsNotExist(err) {
return picoclawSecurityConfig{}, nil
}
return picoclawSecurityConfig{}, fmt.Errorf("failed to read picoclaw security config: %w", err)
}
var sec picoclawSecurityConfig
if err := yaml.Unmarshal(data, &sec); err != nil {
return picoclawSecurityConfig{}, fmt.Errorf("failed to parse picoclaw security config: %w", err)
}
return sec, nil
}

View File

@@ -0,0 +1,71 @@
package picoclaw
import (
"net/http"
"github.com/gin-gonic/gin"
)
const (
CodePicoclawLockHeld = "AI_LOCK_HELD"
CodeScreenshotFailed = "SCREENSHOT_FAILED"
CodeScreenshotNoSignal = "SCREENSHOT_NO_SIGNAL"
CodeHIDWriteFailed = "HID_WRITE_FAILED"
CodeInvalidAction = "INVALID_ACTION"
CodeRuntimeUnavailable = "RUNTIME_UNAVAILABLE"
CodeRuntimeStartFailed = "RUNTIME_START_FAILED"
CodeSessionIDMissing = "SESSION_ID_MISSING"
CodeSessionIDInvalid = "SESSION_ID_INVALID"
)
type PicoclawError struct {
StatusCode int
Code string
Message string
SessionID string
Index *int
}
func (e *PicoclawError) Error() string {
return e.Code + ": " + e.Message
}
func newPicoclawError(code string, message string) *PicoclawError {
return &PicoclawError{
StatusCode: http.StatusOK,
Code: code,
Message: message,
}
}
func writeSuccess(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, gin.H{
"code": 0,
"msg": "success",
"data": data,
})
}
func writePicoclawError(c *gin.Context, err *PicoclawError) {
if err == nil {
return
}
payload := gin.H{
"code": err.Code,
"message": err.Message,
}
if err.SessionID != "" {
payload["session_id"] = err.SessionID
}
if err.Index != nil {
payload["index"] = *err.Index
}
statusCode := err.StatusCode
if statusCode == 0 {
statusCode = http.StatusOK
}
c.JSON(statusCode, payload)
}

View File

@@ -0,0 +1,107 @@
package picoclaw
import (
"fmt"
"net"
"net/http"
"net/url"
"time"
"github.com/gorilla/websocket"
)
func (s *Service) connectGateway(sessionID string) (*websocket.Conn, *PicoclawError) {
cfg := s.config.Get()
gatewayURL, err := buildGatewayURL(cfg, sessionID)
if err != nil {
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Status = "config_error"
status.ConfigError = err.Error()
status.LastError = err.Error()
status.CheckedAt = time.Now()
status.CurrentSession = s.lock.Owner()
})
return nil, newPicoclawError(CodeRuntimeUnavailable, err.Error())
}
header := http.Header{}
if cfg.Token != "" {
header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.Token))
}
dialer := websocket.Dialer{
HandshakeTimeout: time.Duration(cfg.ConnectTimeoutMs) * time.Millisecond,
NetDialContext: (&net.Dialer{
Timeout: time.Duration(cfg.ConnectTimeoutMs) * time.Millisecond,
}).DialContext,
}
upstream, response, err := dialer.Dial(gatewayURL, header)
if err != nil {
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Status = "unavailable"
status.ConfigError = ""
status.LastError = err.Error()
status.CheckedAt = time.Now()
status.CurrentSession = s.lock.Owner()
if response != nil && response.StatusCode == http.StatusUnauthorized {
status.Status = "config_error"
status.ConfigError = "gateway authentication failed"
}
})
return nil, newPicoclawError(CodeRuntimeUnavailable, "gateway is unavailable")
}
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = true
status.Status = "ready"
status.ConfigError = ""
status.LastError = ""
status.CheckedAt = time.Now()
status.CurrentSession = s.lock.Owner()
})
return upstream, nil
}
func buildGatewayURL(cfg Config, sessionID string) (string, error) {
parsed, err := url.Parse(cfg.GatewayURL)
if err != nil {
return "", fmt.Errorf("invalid gateway url: %w", err)
}
if parsed.Scheme != "ws" && parsed.Scheme != "wss" {
return "", fmt.Errorf("invalid gateway url scheme: %s", parsed.Scheme)
}
if parsed.Host == "" {
return "", fmt.Errorf("invalid gateway url host")
}
query := parsed.Query()
query.Set("session_id", sessionID)
if cfg.Token != "" && cfg.AllowTokenQuery {
query.Set("token", cfg.Token)
}
parsed.RawQuery = query.Encode()
return parsed.String(), nil
}
func gatewayHostPort(parsed *url.URL) (string, error) {
if parsed == nil || parsed.Host == "" {
return "", fmt.Errorf("invalid gateway url host")
}
if _, _, err := net.SplitHostPort(parsed.Host); err == nil {
return parsed.Host, nil
}
switch parsed.Scheme {
case "ws":
return net.JoinHostPort(parsed.Host, "80"), nil
case "wss":
return net.JoinHostPort(parsed.Host, "443"), nil
default:
return "", fmt.Errorf("invalid gateway url scheme: %s", parsed.Scheme)
}
}

View File

@@ -0,0 +1,277 @@
package picoclaw
import (
"fmt"
"net/http"
"strings"
"sync"
"time"
"NanoKVM-Server/service/stream/mjpeg"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/gorilla/websocket"
log "github.com/sirupsen/logrus"
)
var gatewayUpgrader = websocket.Upgrader{
ReadBufferSize: 4096,
WriteBufferSize: 4096,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
type relayResult struct {
Source string
CloseCode int
Reason string
}
func (s *Service) ConnectGateway(c *gin.Context) {
sessionID := strings.TrimSpace(c.Query("session_id"))
if sessionID == "" {
sessionID = uuid.NewString()
}
if lockErr := s.lock.Ensure(sessionID); lockErr != nil {
c.AbortWithStatusJSON(http.StatusConflict, gin.H{
"code": CodePicoclawLockHeld,
"message": lockErr.Message,
})
return
}
session, sessionErr := GetSessionManager().Register(sessionID, nil)
if sessionErr != nil {
s.lock.Release(sessionID)
c.AbortWithStatusJSON(http.StatusConflict, gin.H{
"code": CodePicoclawLockHeld,
"message": sessionErr.Message,
})
return
}
GetSessionManager().SetState(sessionID, SessionStateConnecting)
if runtimeErr := s.syncConfigFromPicoclaw(); runtimeErr != nil {
s.closeGatewaySession(session, CloseCodeRuntimeUnavailable, runtimeErr.Message)
return
}
upstream, gatewayErr := s.connectGateway(sessionID)
if gatewayErr != nil {
s.closeGatewaySession(session, CloseCodeRuntimeUnavailable, gatewayErr.Message)
return
}
downstream, err := gatewayUpgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Errorf("failed to upgrade gateway websocket: %s", err)
_ = upstream.Close()
ReleaseSession(sessionID)
GetSessionManager().SetState(sessionID, SessionStateClosed)
GetSessionManager().Remove(sessionID)
return
}
GetSessionManager().AttachUpstream(sessionID, upstream)
GetSessionManager().AttachDownstream(sessionID, downstream)
GetSessionManager().SetState(sessionID, SessionStateActive)
mjpeg.EnableLatestFrameCache()
var wg sync.WaitGroup
results := make(chan relayResult, 2)
cfg := s.config.Get()
s.configureRelayConn(downstream, cfg)
s.configureRelayConn(upstream, cfg)
wg.Add(4)
go s.runPingLoop("downstream", session.SessionID, downstream, cfg, &wg)
go s.runPingLoop("upstream", session.SessionID, upstream, cfg, &wg)
go s.proxyMessages("downstream", session, downstream, cfg, &wg, results)
go s.proxyMessages("upstream", session, upstream, cfg, &wg, results)
result := <-results
closeCode := result.CloseCode
if closeCode == 0 {
closeCode = websocket.CloseNormalClosure
}
if result.Source == "upstream" {
if closeCode == websocket.CloseNormalClosure {
closeCode = CloseCodeUpstreamClosed
}
s.closeGatewaySession(session, closeCode, result.Reason)
} else {
s.closeGatewaySession(session, closeCode, result.Reason)
}
wg.Wait()
}
func (s *Service) proxyMessages(source string, session *GatewaySession, src *websocket.Conn, cfg Config, wg *sync.WaitGroup, results chan<- relayResult) {
defer wg.Done()
for {
messageType, data, err := src.ReadMessage()
if err != nil {
results <- relayResult{
Source: source,
CloseCode: closeCodeFromError(source, err),
Reason: closeReasonFromError(err),
}
return
}
if source == "upstream" && messageType == websocket.BinaryMessage {
results <- relayResult{
Source: source,
CloseCode: CloseCodeUpstreamClosed,
Reason: "upstream sent unsupported binary message",
}
return
}
if !s.lock.Renew(session.SessionID) {
results <- relayResult{
Source: source,
CloseCode: CloseCodePicoclawTakenOver,
Reason: "session lock lost",
}
return
}
var writeErr error
switch source {
case "downstream":
writeErr = session.writeUpstreamMessage(cfg, messageType, data)
default:
writeErr = session.writeDownstreamMessage(cfg, messageType, data)
}
if writeErr != nil {
results <- relayResult{
Source: source,
CloseCode: closeCodeFromError(source, writeErr),
Reason: closeReasonFromError(writeErr),
}
return
}
}
}
func (s *Service) runPingLoop(name string, sessionID string, conn *websocket.Conn, cfg Config, wg *sync.WaitGroup) {
defer wg.Done()
ticker := time.NewTicker(time.Duration(cfg.PingIntervalMs) * time.Millisecond)
defer ticker.Stop()
for range ticker.C {
if !s.lock.Renew(sessionID) {
writeGatewayClose(conn, CloseCodePicoclawTakenOver, "session lock lost")
_ = conn.Close()
return
}
if err := conn.WriteControl(
websocket.PingMessage,
[]byte(name),
time.Now().Add(time.Duration(cfg.WriteTimeoutMs)*time.Millisecond),
); err != nil {
return
}
}
}
func (s *Service) configureRelayConn(conn *websocket.Conn, cfg Config) {
readTimeout := time.Duration(cfg.ReadTimeoutMs) * time.Millisecond
conn.SetReadLimit(int64(cfg.MaxMessageBytes))
_ = conn.SetReadDeadline(time.Now().Add(readTimeout))
conn.SetPongHandler(func(string) error {
return conn.SetReadDeadline(time.Now().Add(readTimeout))
})
}
func (s *Service) closeGatewaySession(session *GatewaySession, closeCode int, reason string) {
if session == nil {
return
}
session.closeOnce.Do(func() {
hadDownstream := session.Downstream != nil
mjpeg.DisableLatestFrameCache()
GetSessionManager().SetState(session.SessionID, SessionStateClosing)
if session.Upstream != nil {
writeGatewayClose(session.Upstream, websocket.CloseNormalClosure, "relay closing")
_ = session.Upstream.Close()
}
if session.Downstream != nil {
writeGatewayClose(session.Downstream, closeCode, reason)
_ = session.Downstream.Close()
}
if hadDownstream {
cleanupPicoclawMediaTempDir()
}
ReleaseSession(session.SessionID)
GetSessionManager().SetState(session.SessionID, SessionStateClosed)
GetSessionManager().Remove(session.SessionID)
status := s.runtime.Get()
status.CurrentSession = s.lock.Owner()
if closeCode == CloseCodeUpstreamClosed || closeCode == CloseCodeRuntimeUnavailable {
status.Ready = false
if closeCode == CloseCodeUpstreamClosed {
status.Status = "unavailable"
}
status.LastError = reason
status.CheckedAt = time.Now()
s.runtime.Set(status)
}
})
}
func writeGatewayClose(conn *websocket.Conn, code int, reason string) {
if conn == nil {
return
}
message := websocket.FormatCloseMessage(code, reason)
_ = conn.WriteControl(websocket.CloseMessage, message, time.Now().Add(2*time.Second))
}
func closeCodeFromError(source string, err error) int {
if closeErr, ok := err.(*websocket.CloseError); ok {
if closeErr.Code == websocket.CloseNormalClosure || closeErr.Code == websocket.CloseGoingAway {
return websocket.CloseNormalClosure
}
if closeErr.Code == websocket.CloseAbnormalClosure || closeErr.Code == websocket.CloseNoStatusReceived {
if source == "upstream" {
return CloseCodeUpstreamClosed
}
return websocket.CloseNormalClosure
}
return closeErr.Code
}
if source == "upstream" {
return CloseCodeUpstreamClosed
}
return websocket.CloseNormalClosure
}
func closeReasonFromError(err error) string {
if err == nil {
return "closed"
}
if closeErr, ok := err.(*websocket.CloseError); ok {
if closeErr.Text != "" {
return closeErr.Text
}
}
return fmt.Sprintf("relay closed: %v", err)
}

View File

@@ -0,0 +1,86 @@
package picoclaw
import (
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/gorilla/websocket"
)
type picoGatewayMessage struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
SessionID string `json:"session_id,omitempty"`
Timestamp int64 `json:"timestamp,omitempty"`
Payload map[string]any `json:"payload,omitempty"`
}
func newPicoGatewayMessage(sessionID string, payload map[string]any) picoGatewayMessage {
return picoGatewayMessage{
Type: "message.send",
ID: uuid.NewString(),
SessionID: sessionID,
Timestamp: time.Now().UnixMilli(),
Payload: payload,
}
}
func newPicoGatewayObservationMessage(sessionID string, payload map[string]any) picoGatewayMessage {
return picoGatewayMessage{
Type: "observation",
ID: uuid.NewString(),
SessionID: sessionID,
Timestamp: time.Now().UnixMilli(),
Payload: payload,
}
}
func (session *GatewaySession) writeUpstreamJSON(cfg Config, value any) error {
raw, err := json.Marshal(value)
if err != nil {
return err
}
return session.writeUpstreamMessage(cfg, websocket.TextMessage, raw)
}
func (session *GatewaySession) writeDownstreamJSON(cfg Config, value any) error {
raw, err := json.Marshal(value)
if err != nil {
return err
}
return session.writeDownstreamMessage(cfg, websocket.TextMessage, raw)
}
func (session *GatewaySession) writeUpstreamMessage(cfg Config, messageType int, data []byte) error {
if session == nil || session.Upstream == nil {
return fmt.Errorf("upstream connection is unavailable")
}
session.upstreamWriteMu.Lock()
defer session.upstreamWriteMu.Unlock()
_ = session.Upstream.SetWriteDeadline(gatewayWriteDeadline(cfg))
return session.Upstream.WriteMessage(messageType, data)
}
func (session *GatewaySession) writeDownstreamMessage(cfg Config, messageType int, data []byte) error {
if session == nil || session.Downstream == nil {
return fmt.Errorf("downstream connection is unavailable")
}
session.downstreamWriteMu.Lock()
defer session.downstreamWriteMu.Unlock()
_ = session.Downstream.SetWriteDeadline(gatewayWriteDeadline(cfg))
return session.Downstream.WriteMessage(messageType, data)
}
func gatewayWriteDeadline(cfg Config) time.Time {
timeout := time.Duration(cfg.WriteTimeoutMs) * time.Millisecond
if timeout <= 0 {
timeout = 10 * time.Second
}
return time.Now().Add(timeout)
}

View File

@@ -0,0 +1,271 @@
package picoclaw
import (
"fmt"
"strings"
)
const (
modifierLeftCtrl = 1 << 0
modifierLeftShift = 1 << 1
modifierLeftAlt = 1 << 2
modifierLeftMeta = 1 << 3
modifierRightCtrl = 1 << 4
modifierRightShift = 1 << 5
modifierRightAlt = 1 << 6
modifierRightMeta = 1 << 7
)
var modifierMap = map[string]byte{
"ControlLeft": modifierLeftCtrl,
"ShiftLeft": modifierLeftShift,
"AltLeft": modifierLeftAlt,
"MetaLeft": modifierLeftMeta,
"ControlRight": modifierRightCtrl,
"ShiftRight": modifierRightShift,
"AltRight": modifierRightAlt,
"MetaRight": modifierRightMeta,
}
var keyAliases = map[string]string{
"CTRL": "ControlLeft",
"CONTROL": "ControlLeft",
"SHIFT": "ShiftLeft",
"ALT": "AltLeft",
"OPTION": "AltLeft",
"META": "MetaLeft",
"WIN": "MetaLeft",
"WINDOWS": "MetaLeft",
"COMMAND": "MetaLeft",
"CMD": "MetaLeft",
"SUPER": "MetaLeft",
"ESC": "Escape",
"RETURN": "Enter",
"DEL": "Delete",
"INS": "Insert",
"UP": "ArrowUp",
"DOWN": "ArrowDown",
"LEFT": "ArrowLeft",
"RIGHT": "ArrowRight",
"SPACEBAR": "Space",
"SPACE": "Space",
"TAB": "Tab",
"ENTER": "Enter",
"BACKSPACE": "Backspace",
"PGUP": "PageUp",
"PGDN": "PageDown",
}
var keycodeMap = map[string]byte{
"KeyA": 0x04, "KeyB": 0x05, "KeyC": 0x06, "KeyD": 0x07, "KeyE": 0x08,
"KeyF": 0x09, "KeyG": 0x0a, "KeyH": 0x0b, "KeyI": 0x0c, "KeyJ": 0x0d,
"KeyK": 0x0e, "KeyL": 0x0f, "KeyM": 0x10, "KeyN": 0x11, "KeyO": 0x12,
"KeyP": 0x13, "KeyQ": 0x14, "KeyR": 0x15, "KeyS": 0x16, "KeyT": 0x17,
"KeyU": 0x18, "KeyV": 0x19, "KeyW": 0x1a, "KeyX": 0x1b, "KeyY": 0x1c,
"KeyZ": 0x1d,
"Digit1": 0x1e, "Digit2": 0x1f, "Digit3": 0x20, "Digit4": 0x21, "Digit5": 0x22,
"Digit6": 0x23, "Digit7": 0x24, "Digit8": 0x25, "Digit9": 0x26, "Digit0": 0x27,
"Enter": 0x28,
"Escape": 0x29,
"Backspace": 0x2a,
"Tab": 0x2b,
"Space": 0x2c,
"Minus": 0x2d,
"Equal": 0x2e,
"BracketLeft": 0x2f,
"BracketRight": 0x30,
"Backslash": 0x31,
"Semicolon": 0x33,
"Quote": 0x34,
"Backquote": 0x35,
"Comma": 0x36,
"Period": 0x37,
"Slash": 0x38,
"CapsLock": 0x39,
"F1": 0x3a, "F2": 0x3b, "F3": 0x3c, "F4": 0x3d, "F5": 0x3e, "F6": 0x3f,
"F7": 0x40, "F8": 0x41, "F9": 0x42, "F10": 0x43, "F11": 0x44, "F12": 0x45,
"PrintScreen": 0x46,
"ScrollLock": 0x47,
"Pause": 0x48,
"Insert": 0x49,
"Home": 0x4a,
"PageUp": 0x4b,
"Delete": 0x4c,
"End": 0x4d,
"PageDown": 0x4e,
"ArrowRight": 0x4f,
"ArrowLeft": 0x50,
"ArrowDown": 0x51,
"ArrowUp": 0x52,
"NumLock": 0x53,
"NumpadDivide": 0x54,
"NumpadMultiply": 0x55,
"NumpadSubtract": 0x56,
"NumpadAdd": 0x57,
"NumpadEnter": 0x58,
"Numpad1": 0x59,
"Numpad2": 0x5a,
"Numpad3": 0x5b,
"Numpad4": 0x5c,
"Numpad5": 0x5d,
"Numpad6": 0x5e,
"Numpad7": 0x5f,
"Numpad8": 0x60,
"Numpad9": 0x61,
"Numpad0": 0x62,
"NumpadDecimal": 0x63,
"IntlBackslash": 0x64,
"ContextMenu": 0x65,
"Power": 0x66,
"NumpadEqual": 0x67,
"F13": 0x68, "F14": 0x69, "F15": 0x6a, "F16": 0x6b, "F17": 0x6c, "F18": 0x6d,
"F19": 0x6e, "F20": 0x6f, "F21": 0x70, "F22": 0x71, "F23": 0x72, "F24": 0x73,
"Execute": 0x74,
"Help": 0x75,
"Props": 0x76,
"Select": 0x77,
"Stop": 0x78,
"Again": 0x79,
"Undo": 0x7a,
"Cut": 0x7b,
"Copy": 0x7c,
"Paste": 0x7d,
"Find": 0x7e,
"AudioVolumeMute": 0x7f,
"AudioVolumeUp": 0x80,
"AudioVolumeDown": 0x81,
"VolumeMute": 0x7f,
"VolumeUp": 0x80,
"VolumeDown": 0x81,
"LockingCapsLock": 0x82,
"LockingNumLock": 0x83,
"LockingScrollLock": 0x84,
"NumpadComma": 0x85,
"NumpadEqual2": 0x86,
"IntlRo": 0x87,
"KanaMode": 0x88,
"IntlYen": 0x89,
"Convert": 0x8a,
"NonConvert": 0x8b,
"International6": 0x8c,
"International7": 0x8d,
"International8": 0x8e,
"International9": 0x8f,
"Lang1": 0x90, "Lang2": 0x91, "Lang3": 0x92, "Lang4": 0x93, "Lang5": 0x94,
"Lang6": 0x95, "Lang7": 0x96, "Lang8": 0x97, "Lang9": 0x98,
"IntlHash": 0x32,
"NumpadParenLeft": 0xb6,
"NumpadParenRight": 0xb7,
"NumpadBackspace": 0xbb,
"NumpadMemoryStore": 0xd0,
"NumpadMemoryRecall": 0xd1,
"NumpadMemoryClear": 0xd2,
"NumpadMemoryAdd": 0xd3,
"NumpadMemorySubtract": 0xd4,
"NumpadClear": 0xd8,
"NumpadClearEntry": 0xd9,
"BrowserSearch": 0xf0,
"BrowserHome": 0xf1,
"BrowserBack": 0xf2,
"BrowserForward": 0xf3,
"BrowserStop": 0xf4,
"BrowserRefresh": 0xf5,
"BrowserFavorites": 0xf6,
"MediaPlayPause": 0xe8,
"MediaStop": 0xe9,
"MediaTrackPrevious": 0xea,
"MediaTrackNext": 0xeb,
"Eject": 0xec,
"MediaSelect": 0xed,
"LaunchMail": 0xee,
"LaunchApp1": 0xef,
"LaunchApp2": 0xf0,
"Sleep": 0xf8,
"Wake": 0xf9,
"MediaRewind": 0xfa,
"MediaFastForward": 0xfb,
}
func resolveKey(key string) (string, byte, bool, error) {
trimmed := strings.TrimSpace(key)
if trimmed == "" {
return "", 0, false, fmt.Errorf("empty key")
}
if modifier, ok := modifierMap[trimmed]; ok {
return trimmed, modifier, true, nil
}
if _, ok := keycodeMap[trimmed]; ok {
return trimmed, keycodeMap[trimmed], false, nil
}
upper := strings.ToUpper(trimmed)
if alias, ok := keyAliases[upper]; ok {
if modifier, ok := modifierMap[alias]; ok {
return alias, modifier, true, nil
}
return alias, keycodeMap[alias], false, nil
}
if len(upper) == 1 && upper[0] >= 'A' && upper[0] <= 'Z' {
code := "Key" + upper
return code, keycodeMap[code], false, nil
}
if len(upper) == 1 && upper[0] >= '0' && upper[0] <= '9' {
code := "Digit" + upper
return code, keycodeMap[code], false, nil
}
if modifier, ok := modifierMap[upper]; ok {
return upper, modifier, true, nil
}
if code, ok := keycodeMap[trimmed]; ok {
return trimmed, code, false, nil
}
if code, ok := keycodeMap[upper]; ok {
return upper, code, false, nil
}
return "", 0, false, fmt.Errorf("unsupported key: %s", key)
}
func buildHotkeyReport(keys []string) ([]byte, *PicoclawError) {
if len(keys) == 0 {
return nil, newPicoclawError(CodeInvalidAction, "hotkey requires at least one key")
}
report := make([]byte, 8)
nextIndex := 2
for _, key := range keys {
_, code, isModifier, err := resolveKey(key)
if err != nil {
return nil, newPicoclawError(CodeInvalidAction, err.Error())
}
if isModifier {
report[0] |= code
continue
}
if nextIndex >= len(report) {
return nil, newPicoclawError(CodeInvalidAction, "hotkey supports at most 6 non-modifier keys")
}
report[nextIndex] = code
nextIndex++
}
return report, nil
}

View File

@@ -0,0 +1,102 @@
package picoclaw
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/gin-gonic/gin"
)
const (
defaultImagePrompt = "Describe the image briefly."
)
func (s *Service) LoadImage(c *gin.Context) {
var req LoadImageRequest
if err := c.ShouldBindJSON(&req); err != nil {
writePicoclawError(c, newPicoclawError(CodeInvalidAction, "invalid load image payload"))
return
}
sessionID, session, sessionErr := s.requireActiveGatewaySession(c)
if sessionErr != nil {
writePicoclawError(c, sessionErr)
return
}
sourcePath, pathErr := normalizeLoadImagePath(req.Path)
if pathErr != nil {
writePicoclawError(c, pathErr)
return
}
instruction := buildLoadImageInstruction(sourcePath, req.Prompt)
message := newPicoGatewayMessage(sessionID, map[string]any{
"content": instruction,
})
if err := session.writeUpstreamJSON(s.config.Get(), message); err != nil {
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "failed to deliver image instruction to picoclaw"))
return
}
writeSuccess(c, gin.H{
"accepted": true,
"session_id": sessionID,
"message_id": message.ID,
"path": sourcePath,
"user_prompt": normalizeImagePrompt(req.Prompt),
"instructed_at": time.Now(),
})
}
func (s *Service) requireActiveGatewaySession(c *gin.Context) (string, *GatewaySession, *PicoclawError) {
sessionID := strings.TrimSpace(c.GetHeader(sessionIDHeader))
if sessionID == "" {
sessionID = strings.TrimSpace(s.lock.Owner())
}
if sessionID == "" {
return "", nil, newPicoclawError(CodeSessionIDMissing, "missing X-PicoClaw-Session-ID")
}
session, ok := GetSessionManager().Get(sessionID)
if !ok || session == nil {
err := newPicoclawError(CodeRuntimeUnavailable, "picoclaw session is not connected")
err.SessionID = sessionID
return "", nil, err
}
if session.State != SessionStateActive || session.Upstream == nil {
err := newPicoclawError(CodeRuntimeUnavailable, "picoclaw session is not active")
err.SessionID = sessionID
return "", nil, err
}
return sessionID, session, nil
}
func normalizeLoadImagePath(sourcePath string) (string, *PicoclawError) {
sourcePath = strings.TrimSpace(sourcePath)
if sourcePath == "" {
return "", newPicoclawError(CodeInvalidAction, "image path is required")
}
return sourcePath, nil
}
func buildLoadImageInstruction(sourcePath string, prompt string) string {
argsJSON, _ := json.Marshal(map[string]string{"path": sourcePath})
return fmt.Sprintf(
"Call `load_image` first with this exact argument object:\n```json\n%s\n```\n\nDo not ask the user to re-upload the image. Do not modify the path. Do not use `read_file` or any other tool to inspect this image directly.\n\nAfter `load_image` succeeds, treat the loaded image as the current task input and continue with this request:\n%s",
string(argsJSON),
normalizeImagePrompt(prompt),
)
}
func normalizeImagePrompt(prompt string) string {
prompt = strings.TrimSpace(prompt)
if prompt == "" {
return defaultImagePrompt
}
return prompt
}

View File

@@ -0,0 +1,299 @@
package picoclaw
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
// JSON-RPC 2.0 types for MCP HTTP transport
type jsonRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type jsonRPCResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Result interface{} `json:"result,omitempty"`
Error *jsonRPCError `json:"error,omitempty"`
}
type jsonRPCError struct {
Code int `json:"code"`
Message string `json:"message"`
}
// MCP tool definitions
var mcpToolDefinitions = []map[string]interface{}{
{
"name": "kvm_screenshot",
"description": "Capture the current HDMI frame from the downstream remote host as a base64-encoded JPEG image.",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"width": map[string]interface{}{"type": "integer", "description": "Target width in pixels (optional, default: 960)"},
"height": map[string]interface{}{"type": "integer", "description": "Target height in pixels (optional)"},
"quality": map[string]interface{}{"type": "integer", "description": "JPEG quality 1-100 (optional, default: 60)"},
},
},
},
{
"name": "kvm_actions",
"description": "Send one or more HID actions (click, type, hotkey, scroll, drag, move, wait) to the downstream remote host. Use normalized [0,1] coordinates for mouse actions.",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"actions": map[string]interface{}{
"type": "array",
"description": "Array of action objects. Each requires an 'action' field (click, move, type, hotkey, scroll, drag, wait).",
"items": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{"type": "string"},
"x": map[string]interface{}{"type": "number"},
"y": map[string]interface{}{"type": "number"},
"button": map[string]interface{}{"type": "string"},
"text": map[string]interface{}{"type": "string"},
"keys": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}},
"direction": map[string]interface{}{"type": "string"},
"amount": map[string]interface{}{"type": "integer"},
"duration_ms": map[string]interface{}{"type": "integer"},
"from": map[string]interface{}{"type": "object", "properties": map[string]interface{}{"x": map[string]interface{}{"type": "number"}, "y": map[string]interface{}{"type": "number"}}},
"to": map[string]interface{}{"type": "object", "properties": map[string]interface{}{"x": map[string]interface{}{"type": "number"}, "y": map[string]interface{}{"type": "number"}}},
},
"required": []string{"action"},
},
},
},
"required": []string{"actions"},
},
},
}
// MCPHandler handles MCP HTTP transport (JSON-RPC 2.0 over POST).
func (s *Service) MCPHandler(c *gin.Context) {
var req jsonRPCRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, jsonRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Error: &jsonRPCError{Code: -32700, Message: "parse error"},
})
return
}
if req.JSONRPC != "2.0" {
c.JSON(http.StatusOK, jsonRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Error: &jsonRPCError{Code: -32600, Message: "invalid request: jsonrpc must be 2.0"},
})
return
}
var resp jsonRPCResponse
switch req.Method {
case "initialize":
resp = s.mcpInitialize(req)
case "tools/list":
resp = s.mcpToolsList(req)
case "tools/call":
resp = s.mcpToolsCall(req, c)
case "ping":
resp = jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: map[string]interface{}{}}
default:
resp = jsonRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Error: &jsonRPCError{Code: -32601, Message: fmt.Sprintf("method not found: %s", req.Method)},
}
}
c.JSON(http.StatusOK, resp)
}
func (s *Service) mcpInitialize(req jsonRPCRequest) jsonRPCResponse {
return jsonRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: map[string]interface{}{
"protocolVersion": "2024-11-05",
"capabilities": map[string]interface{}{
"tools": map[string]interface{}{},
},
"serverInfo": map[string]interface{}{
"name": "nanokvm",
"version": "1.0.0",
},
},
}
}
func (s *Service) mcpToolsList(req jsonRPCRequest) jsonRPCResponse {
return jsonRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: map[string]interface{}{
"tools": mcpToolDefinitions,
},
}
}
func (s *Service) mcpToolsCall(req jsonRPCRequest, c *gin.Context) jsonRPCResponse {
var params struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
return jsonRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Error: &jsonRPCError{Code: -32602, Message: "invalid params"},
}
}
switch params.Name {
case "kvm_screenshot":
return s.mcpScreenshot(req, params.Arguments, c)
case "kvm_actions":
return s.mcpActions(req, params.Arguments, c)
default:
return jsonRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Error: &jsonRPCError{Code: -32602, Message: fmt.Sprintf("unknown tool: %s", params.Name)},
}
}
}
func (s *Service) mcpScreenshot(req jsonRPCRequest, args json.RawMessage, c *gin.Context) jsonRPCResponse {
var params struct {
Width uint16 `json:"width"`
Height uint16 `json:"height"`
Quality uint16 `json:"quality"`
}
if args != nil {
_ = json.Unmarshal(args, &params)
}
query := ScreenshotQuery{
Format: "base64",
Width: params.Width,
Height: params.Height,
Quality: params.Quality,
}
data, meta, err := s.captureScreenshot(query)
if err != nil {
return mcpToolError(req, err.Message)
}
b64 := base64.StdEncoding.EncodeToString(data)
s.publishMCPObservation(c, "screenshot captured", b64)
return jsonRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: map[string]interface{}{
"content": []map[string]interface{}{
{
"type": "text",
"text": "screenshot captured",
},
{
"type": "image",
"data": b64,
"mimeType": "image/jpeg",
},
},
"meta": map[string]interface{}{
"source_width": meta.SourceWidth,
"source_height": meta.SourceHeight,
"capture_width": meta.CaptureWidth,
"capture_height": meta.CaptureHeight,
},
},
}
}
func (s *Service) publishMCPObservation(c *gin.Context, text string, imageBase64 string) {
if strings.TrimSpace(imageBase64) == "" {
return
}
sessionID, session, err := s.requireActiveGatewaySession(c)
if err != nil || session == nil || session.Downstream == nil {
return
}
message := newPicoGatewayObservationMessage(sessionID, map[string]any{
"content": text,
"image_base64": imageBase64,
"mime_type": "image/jpeg",
})
if writeErr := session.writeDownstreamJSON(s.config.Get(), message); writeErr != nil {
log.Warnf("failed to deliver picoclaw observation message: %v", writeErr)
}
}
func (s *Service) mcpActions(req jsonRPCRequest, args json.RawMessage, c *gin.Context) jsonRPCResponse {
var params struct {
Actions []Action `json:"actions"`
}
if err := json.Unmarshal(args, &params); err != nil {
return mcpToolError(req, "invalid actions payload")
}
if len(params.Actions) == 0 {
return mcpToolError(req, "empty actions array")
}
sessionID := c.GetHeader(sessionIDHeader)
if sessionID == "" {
sessionID = s.lock.Owner()
}
result, err := s.executeActions(sessionID, params.Actions)
if err != nil {
return mcpToolError(req, err.Message)
}
resultJSON, _ := json.Marshal(result)
return jsonRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: map[string]interface{}{
"content": []map[string]interface{}{
{
"type": "text",
"text": string(resultJSON),
},
},
},
}
}
func mcpToolError(req jsonRPCRequest, message string) jsonRPCResponse {
return jsonRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: map[string]interface{}{
"isError": true,
"content": []map[string]interface{}{
{
"type": "text",
"text": message,
},
},
},
}
}

View File

@@ -0,0 +1,225 @@
package picoclaw
import (
"fmt"
"strings"
"github.com/gin-gonic/gin"
)
func extractPicoclawModelName(model string) string {
model = strings.TrimSpace(model)
if model == "" {
return ""
}
if index := strings.LastIndex(model, "/"); index >= 0 && index < len(model)-1 {
return strings.TrimSpace(model[index+1:])
}
return model
}
func isPicoclawModelConfigured(cfg picoclawConfigFile, security picoclawSecurityConfig, modelName string) bool {
if modelName == "" {
return false
}
for _, model := range cfg.ModelList {
if strings.TrimSpace(model.ModelName) != modelName {
continue
}
if model.APIBase == "" {
continue
}
if securityHasModelAPIKeys(security, modelName) {
return true
}
return configHasModelAPIKeys(model.APIKey, model.APIKeys)
}
return false
}
func configHasModelAPIKeys(apiKey string, apiKeys []string) bool {
return strings.TrimSpace(apiKey) != "" || len(apiKeys) > 0
}
func securityHasModelAPIKeys(security picoclawSecurityConfig, modelName string) bool {
if modelName == "" || len(security.ModelList) == 0 {
return false
}
if entry, ok := security.ModelList[modelName]; ok && len(entry.APIKeys) > 0 {
return true
}
prefix := modelName + ":"
for key, entry := range security.ModelList {
if strings.HasPrefix(key, prefix) && len(entry.APIKeys) > 0 {
return true
}
}
return false
}
type ModelConfigUpdateRequest struct {
Model string `json:"model"`
APIBase string `json:"api_base"`
APIKey string `json:"api_key"`
}
func (s *Service) UpdateModelConfig(c *gin.Context) {
var req ModelConfigUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
writePicoclawError(c, newPicoclawError(CodeInvalidAction, "invalid model config payload"))
return
}
currentStatus := s.runtime.Get()
shouldRestart := currentStatus.Ready || currentStatus.Status == "ready"
modelName, err := updatePicoclawModelConfig(
strings.TrimSpace(req.APIBase),
strings.TrimSpace(req.APIKey),
strings.TrimSpace(req.Model),
)
if err != nil {
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, err.Error()))
return
}
if err := ensurePicoclawStartupDefaults(); err != nil {
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "model config saved, but failed to persist picoclaw defaults: "+err.Error()))
return
}
if shouldRestart {
if _, _, stopErr := s.stopRuntime(); stopErr != nil {
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "model config saved, but failed to restart picoclaw runtime: "+stopErr.Message))
return
}
if _, _, startErr := s.startRuntime(); startErr != nil {
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "model config saved, but failed to restart picoclaw runtime: "+startErr.Message))
return
}
} else {
_ = s.syncConfigFromPicoclaw()
_ = s.ensureRuntimeReady()
}
writeSuccess(c, gin.H{
"model_name": modelName,
"status": s.runtime.Get(),
})
}
func updatePicoclawModelConfig(apiBase string, apiKey string, model string) (string, error) {
if apiBase == "" {
return "", fmt.Errorf("model api_base is required")
}
if apiKey == "" {
return "", fmt.Errorf("model api_key is required")
}
if model == "" {
return "", fmt.Errorf("model identifier is required")
}
modelName := extractPicoclawModelName(model)
if modelName == "" {
return "", fmt.Errorf("model identifier is required")
}
doc, err := loadPicoclawConfigDocument()
if err != nil {
return "", err
}
modelListValue, ok := doc.raw["model_list"].([]any)
if !ok {
modelListValue = []any{}
}
modelUpdated := false
updatedModelIndex := -1
for index, item := range modelListValue {
modelMap, ok := item.(map[string]any)
if !ok {
continue
}
currentModelName := strings.TrimSpace(fmt.Sprintf("%v", modelMap["model_name"]))
if currentModelName != modelName {
continue
}
modelMap["model_name"] = modelName
modelMap["model"] = model
modelMap["api_base"] = apiBase
delete(modelMap, "api_key")
delete(modelMap, "api_keys")
modelUpdated = true
updatedModelIndex = index
break
}
if !modelUpdated {
modelListValue = append(modelListValue, map[string]any{
"model_name": modelName,
"model": model,
"api_base": apiBase,
})
doc.raw["model_list"] = modelListValue
updatedModelIndex = len(modelListValue) - 1
}
agents, ok := doc.raw["agents"].(map[string]any)
if !ok {
agents = map[string]any{}
doc.raw["agents"] = agents
}
defaults, ok := agents["defaults"].(map[string]any)
if !ok {
defaults = map[string]any{}
agents["defaults"] = defaults
}
defaults["model_name"] = modelName
delete(defaults, "model")
if err := doc.saveConfig(); err != nil {
return "", err
}
if doc.security.ModelList == nil {
doc.security.ModelList = map[string]picoclawModelSecurityEntry{}
}
securityModelName := indexedModelName(modelListValue, updatedModelIndex, modelName)
doc.security.ModelList[securityModelName] = picoclawModelSecurityEntry{
APIKeys: []string{apiKey},
}
if err := doc.saveSecurity(); err != nil {
return "", err
}
return modelName, nil
}
func indexedModelName(modelList []any, targetIndex int, modelName string) string {
if targetIndex < 0 {
return modelName
}
currentIndex := 0
for index, item := range modelList {
modelMap, ok := item.(map[string]any)
if !ok {
continue
}
currentModelName := strings.TrimSpace(fmt.Sprintf("%v", modelMap["model_name"]))
if currentModelName != modelName {
continue
}
if index == targetIndex {
return fmt.Sprintf("%s:%d", modelName, currentIndex)
}
currentIndex++
}
return modelName
}

View File

@@ -0,0 +1,22 @@
package picoclaw
import (
"time"
)
const (
picoclawBinaryPath = "/usr/bin/picoclaw"
picoclawCacheDir = "/root/.picoclaw-cache"
picoclawDownloadURL = "https://cdn.sipeed.com/nanokvm/resources/picoclaw/v0.2.8/picoclaw_Linux_riscv64.tar.gz"
picoclawChecksumURL = "https://cdn.sipeed.com/nanokvm/resources/picoclaw/v0.2.8/sha512.txt"
etcInitPicoclawScript = "/etc/init.d/S96picoclaw"
kvmappPicoclawScript = "/kvmapp/system/init.d/S96picoclaw"
picoclawStartTimeout = 15 * time.Second
picoclawStopTimeout = 15 * time.Second
picoclawOnboardTimeout = 60 * time.Second
picoclawInstallTimeout = 12 * time.Minute
picoclawDownloadTimeout = 10 * time.Minute
picoclawStartWaitPeriod = 1500 * time.Millisecond
picoclawStopWaitPeriod = 500 * time.Millisecond
picoclawReadyPollPeriod = 500 * time.Millisecond
)

View File

@@ -0,0 +1,253 @@
package picoclaw
import (
"crypto/rand"
"encoding/hex"
"fmt"
"reflect"
"strings"
serverConfig "NanoKVM-Server/config"
)
const (
defaultPicoclawPingSec = 30
defaultPicoclawReadSec = 60
defaultPicoclawWriteSec = 10
defaultPicoclawMaxConns = 100
)
type picoclawConfigDefault struct {
path []string
value any
}
var picoclawNanoKVMDefaults = []picoclawConfigDefault{
{path: []string{"agents", "defaults", "restrict_to_workspace"}, value: false},
{path: []string{"agents", "defaults", "allow_read_outside_workspace"}, value: true},
{path: []string{"agents", "defaults", "tool_feedback", "enabled"}, value: true},
{path: []string{"gateway", "host"}, value: defaultPicoclawGatewayHost},
{path: []string{"gateway", "port"}, value: defaultPicoclawGatewayPort},
{path: []string{"gateway", "hot_reload"}, value: false},
{path: []string{"tools", "cron", "allow_command"}, value: true},
{path: []string{"tools", "exec", "allow_remote"}, value: true},
{path: []string{"tools", "exec", "enable_deny_patterns"}, value: false},
{path: []string{"channel_list", "pico", "type"}, value: "pico"},
{path: []string{"channel_list", "pico", "settings", "allow_token_query"}, value: false},
{path: []string{"channel_list", "pico", "settings", "ping_interval"}, value: defaultPicoclawPingSec},
{path: []string{"channel_list", "pico", "settings", "read_timeout"}, value: defaultPicoclawReadSec},
{path: []string{"channel_list", "pico", "settings", "write_timeout"}, value: defaultPicoclawWriteSec},
{path: []string{"channel_list", "pico", "settings", "max_connections"}, value: defaultPicoclawMaxConns},
{path: []string{"tools", "mcp", "enabled"}, value: true},
}
func ensurePicoclawStartupDefaults() error {
doc, err := loadPicoclawConfigDocument()
if err != nil {
return err
}
editor := &picoclawConfigEditor{raw: doc.raw}
forceEnablePicoclawPicoChannel(doc, editor)
if err := applyPicoclawStartupDefaults(editor); err != nil {
return err
}
tokenChanged, err := ensurePicoclawPicoToken(doc)
if err != nil {
return err
}
if tokenChanged {
if err := doc.saveSecurity(); err != nil {
return err
}
}
if editor.changed {
if err := doc.saveConfig(); err != nil {
return err
}
}
return nil
}
func ensurePicoclawPicoChannelEnabled(doc *picoclawConfigDocument) error {
if doc == nil {
return nil
}
if pico, ok := doc.config.Channels["pico"]; ok && pico.Enabled {
return nil
}
editor := &picoclawConfigEditor{raw: doc.raw}
forceEnablePicoclawPicoChannel(doc, editor)
if !editor.changed {
return nil
}
return doc.saveConfig()
}
func applyPicoclawStartupDefaults(editor *picoclawConfigEditor) error {
for _, entry := range picoclawNanoKVMDefaults {
editor.setValue(entry.value, entry.path...)
}
server, err := defaultPicoclawMCPServer()
if err != nil {
return err
}
editor.setMCPServer("nanokvm", server)
return nil
}
func forceEnablePicoclawPicoChannel(doc *picoclawConfigDocument, editor *picoclawConfigEditor) {
if doc == nil || editor == nil {
return
}
if pico, ok := doc.config.Channels["pico"]; ok && pico.Enabled {
return
}
editor.setValue(true, "channel_list", "pico", "enabled")
if doc.config.Channels == nil {
doc.config.Channels = make(map[string]picoclawChannelEntry)
}
pico := doc.config.Channels["pico"]
pico.Enabled = true
doc.config.Channels["pico"] = pico
}
func defaultPicoclawMCPServer() (map[string]any, error) {
internalToken, err := serverConfig.GetPicoclawInternalToken()
if err != nil {
return nil, err
}
return map[string]any{
"enabled": true,
"type": "http",
"url": nanoKVMMCPURL(),
"headers": map[string]any{
serverConfig.PicoclawInternalTokenHeader: internalToken,
},
}, nil
}
func nanoKVMMCPURL() string {
port := serverConfig.GetInstance().Port.Http
if port <= 0 {
port = 80
}
return fmt.Sprintf("http://%s:%d/api/picoclaw/mcp", defaultPicoclawGatewayHost, port)
}
type picoclawConfigEditor struct {
raw map[string]any
changed bool
}
func (e *picoclawConfigEditor) setValue(value any, path ...string) {
if len(path) == 0 {
return
}
target := e.ensureObject(path[:len(path)-1]...)
key := path[len(path)-1]
if current, exists := target[key]; exists && reflect.DeepEqual(current, value) {
return
}
target[key] = value
e.changed = true
}
func (e *picoclawConfigEditor) ensureObject(path ...string) map[string]any {
current := e.raw
for _, key := range path {
next, ok := current[key].(map[string]any)
if !ok {
next = map[string]any{}
current[key] = next
e.changed = true
}
current = next
}
return current
}
func (e *picoclawConfigEditor) setMCPServer(name string, fields map[string]any) {
servers := e.ensureObject("tools", "mcp", "servers")
entry, ok := servers[name].(map[string]any)
if !ok {
entry = map[string]any{}
servers[name] = entry
e.changed = true
}
for key, value := range fields {
if current, exists := entry[key]; exists && reflect.DeepEqual(current, value) {
continue
}
entry[key] = value
e.changed = true
}
}
func generatePicoclawToken() (string, error) {
buf := make([]byte, 24)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("failed to generate picoclaw pico token: %w", err)
}
return hex.EncodeToString(buf), nil
}
func ensurePicoclawPicoToken(doc *picoclawConfigDocument) (bool, error) {
token, err := picoSecurityToken(doc)
if err != nil {
return false, err
}
if doc.security.ChannelList == nil {
doc.security.ChannelList = make(map[string]picoclawChannelSecurityEntry)
}
entry := doc.security.ChannelList["pico"]
if entry.Settings == nil {
entry.Settings = &picoclawChannelSecuritySettings{}
}
if strings.TrimSpace(entry.Settings.Token) == token {
return false, nil
}
entry.Settings.Token = token
doc.security.ChannelList["pico"] = entry
return true, nil
}
func picoSecurityToken(doc *picoclawConfigDocument) (string, error) {
if doc == nil {
return "", nil
}
// Check security channel_list.pico.settings.token
if entry, ok := doc.security.ChannelList["pico"]; ok {
if entry.Settings != nil {
if token := strings.TrimSpace(entry.Settings.Token); token != "" {
return token, nil
}
}
if token := strings.TrimSpace(entry.Token); token != "" {
return token, nil
}
}
// Fall back to config channel_list.pico.settings.token
if pico, ok := doc.config.Channels["pico"]; ok {
if token := strings.TrimSpace(pico.Settings.Token); token != "" {
return token, nil
}
}
return generatePicoclawToken()
}

View File

@@ -0,0 +1,110 @@
package picoclaw
import (
"os"
"path/filepath"
"time"
"github.com/gin-gonic/gin"
)
func (s *Service) StartRuntime(c *gin.Context) {
command, output, err := s.startRuntime()
if err != nil {
writePicoclawError(c, err)
return
}
writeSuccess(c, RuntimeStartResult{
Started: true,
Command: command,
Output: output,
Status: s.runtime.Get(),
})
}
func (s *Service) StopRuntime(c *gin.Context) {
command, output, err := s.stopRuntime()
if err != nil {
writePicoclawError(c, err)
return
}
writeSuccess(c, RuntimeStartResult{
Started: false,
Command: command,
Output: output,
Status: s.runtime.Get(),
})
}
func (s *Service) InstallRuntime(c *gin.Context) {
output, err := s.installRuntime()
if err != nil {
writePicoclawError(c, err)
return
}
currentStatus := s.runtime.Get()
writeSuccess(c, RuntimeInstallResult{
Installed: currentStatus.Installed && !currentStatus.Installing,
Binary: picoclawBinaryPath,
Download: picoclawDownloadURL,
Output: output,
Status: currentStatus,
})
}
func (s *Service) UninstallRuntime(c *gin.Context) {
currentStatus := s.runtime.Get()
uninstallOutput := "picoclaw uninstalled successfully"
if currentStatus.Installing {
writePicoclawError(c, newPicoclawError(CodeRuntimeStartFailed, "cannot uninstall while installation is in progress"))
return
}
running, err := isRuntimeRunning()
if err != nil {
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "failed to check picoclaw runtime status"))
return
}
if running || currentStatus.Ready || currentStatus.Status == "ready" {
if _, _, stopErr := s.stopRuntime(); stopErr != nil {
uninstallOutput = "picoclaw uninstalled successfully (stop failed before uninstall: " + stopErr.Message + ")"
}
}
if configPath, err := resolvePicoclawConfigPath(); err == nil {
_ = os.RemoveAll(filepath.Dir(configPath))
} else {
home, err := os.UserHomeDir()
if err == nil {
_ = os.RemoveAll(filepath.Join(home, ".picoclaw"))
} else {
_ = os.RemoveAll("/root/.picoclaw")
}
}
_ = os.Remove(picoclawBinaryPath)
_ = os.RemoveAll(picoclawCacheDir)
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: false,
Installing: false,
InstallProgress: 0,
InstallStage: "",
InstallPath: picoclawBinaryPath,
Status: "not_installed",
CheckedAt: time.Now(),
CurrentSession: "",
})
writeSuccess(c, RuntimeInstallResult{
Installed: false,
Binary: picoclawBinaryPath,
Download: picoclawDownloadURL,
Output: uninstallOutput,
Status: s.runtime.Get(),
})
}

View File

@@ -0,0 +1,441 @@
package picoclaw
import (
"archive/tar"
"compress/gzip"
"context"
"crypto/sha512"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
log "github.com/sirupsen/logrus"
)
func (s *Service) installRuntime() (string, *PicoclawError) {
log.Debugf("picoclaw install: start, binary=%s, cache=%s", picoclawBinaryPath, picoclawCacheDir)
currentStatus := s.runtime.Get()
if currentStatus.Installing {
log.Debugf("picoclaw install: install already in progress")
return "picoclaw installation is already in progress", nil
}
if installed, err := isPicoclawInstalled(); err == nil && installed {
settings, _ := loadPicoclawGatewaySettings()
log.Debugf("picoclaw install: binary already exists at %s", picoclawBinaryPath)
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
Installing: false,
InstallProgress: 100,
InstallStage: "installed",
InstallPath: picoclawBinaryPath,
ModelConfigured: settings.ModelConfigured,
ModelName: settings.ModelName,
Status: "installed",
CheckedAt: time.Now(),
})
return "picoclaw is already installed", nil
}
ctx, cancel := context.WithTimeout(context.Background(), picoclawInstallTimeout)
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: false,
Installing: true,
InstallProgress: 0,
InstallStage: "preparing",
InstallPath: picoclawBinaryPath,
Status: "installing",
CheckedAt: time.Now(),
})
go s.runInstallRuntime(ctx, cancel)
return "picoclaw installation started", nil
}
func (s *Service) runInstallRuntime(ctx context.Context, cancel context.CancelFunc) {
defer cancel()
_ = os.RemoveAll(picoclawCacheDir)
if err := os.MkdirAll(picoclawCacheDir, 0o755); err != nil {
log.Errorf("picoclaw install: failed to create cache directory %s: %v", picoclawCacheDir, err)
s.finishInstallFailure("install_failed", fmt.Sprintf("failed to create cache directory: %v", err))
return
}
log.Debugf("picoclaw install: cache directory ready at %s", picoclawCacheDir)
defer func() {
if err := os.RemoveAll(picoclawCacheDir); err != nil {
log.Errorf("picoclaw install: failed to clean cache directory %s: %v", picoclawCacheDir, err)
return
}
log.Debugf("picoclaw install: cleaned cache directory %s", picoclawCacheDir)
}()
s.setInstallProgress("downloading", 5, "")
log.Debugf("picoclaw install: downloading checksum from %s", picoclawChecksumURL)
expectedDigest, err := downloadPicoclawChecksum(ctx)
if err != nil {
log.Errorf("picoclaw install: checksum download failed: %v", err)
s.finishInstallFailure(installFailureStatus(err), err.Error())
return
}
log.Debug("picoclaw install: checksum file downloaded")
archivePath := filepath.Join(picoclawCacheDir, "picoclaw.tar.gz")
log.Debugf("picoclaw install: downloading archive from %s to %s", picoclawDownloadURL, archivePath)
if err := downloadPicoclawArchive(ctx, archivePath, func(downloaded int64, total int64) {
progress := 10
if total > 0 {
progress = 10 + int(float64(downloaded)*70/float64(total))
if progress > 80 {
progress = 80
}
}
s.setInstallProgress("downloading", progress, "")
}); err != nil {
log.Errorf("picoclaw install: download failed: %v", err)
s.finishInstallFailure(installFailureStatus(err), err.Error())
return
}
log.Debugf("picoclaw install: archive download completed")
s.setInstallProgress("verifying", 82, "")
if err := verifyFileSHA512(archivePath, expectedDigest); err != nil {
log.Errorf("picoclaw install: checksum verification failed: %v", err)
s.finishInstallFailure(installFailureStatus(err), err.Error())
return
}
log.Debugf("picoclaw install: archive checksum verified for %s", archivePath)
s.setInstallProgress("extracting", 85, "")
log.Debugf("picoclaw install: extracting binary from %s", archivePath)
extractedPath, err := extractPicoclawBinary(archivePath, picoclawCacheDir)
if err != nil {
log.Errorf("picoclaw install: extract failed: %v", err)
s.finishInstallFailure(installFailureStatus(err), err.Error())
return
}
log.Debugf("picoclaw install: extracted binary to %s", extractedPath)
s.setInstallProgress("installing", 95, "")
log.Debugf("picoclaw install: installing binary to %s", picoclawBinaryPath)
if err := installPicoclawBinary(extractedPath, picoclawBinaryPath); err != nil {
log.Errorf("picoclaw install: install failed: %v", err)
s.finishInstallFailure(installFailureStatus(err), err.Error())
return
}
log.Debugf("picoclaw install: install completed successfully")
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
Installing: false,
InstallProgress: 100,
InstallStage: "installed",
InstallPath: picoclawBinaryPath,
Status: "installed",
CheckedAt: time.Now(),
})
}
func downloadPicoclawArchive(ctx context.Context, destination string, onProgress func(downloaded int64, total int64)) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, picoclawDownloadURL, nil)
if err != nil {
return fmt.Errorf("failed to create download request: %w", err)
}
client := &http.Client{
Timeout: picoclawDownloadTimeout,
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to download picoclaw: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to download picoclaw: unexpected status %s", resp.Status)
}
file, err := os.Create(destination)
if err != nil {
return fmt.Errorf("failed to create archive file: %w", err)
}
defer file.Close()
if err := copyWithProgress(ctx, file, resp.Body, resp.ContentLength, onProgress); err != nil {
return fmt.Errorf("failed to save archive: %w", err)
}
return nil
}
func downloadPicoclawChecksum(ctx context.Context) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, picoclawChecksumURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create checksum request: %w", err)
}
client := &http.Client{
Timeout: picoclawDownloadTimeout,
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to download picoclaw checksum: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("failed to download picoclaw checksum: unexpected status %s", resp.Status)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 16*1024))
if err != nil {
return "", fmt.Errorf("failed to read checksum file: %w", err)
}
digest, err := parseSHA512Digest(string(data), filepath.Base(picoclawDownloadURL))
if err != nil {
return "", err
}
return digest, nil
}
func copyWithProgress(ctx context.Context, dst io.Writer, src io.Reader, total int64, onProgress func(downloaded int64, total int64)) error {
buffer := make([]byte, 32*1024)
var downloaded int64
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
n, readErr := src.Read(buffer)
if n > 0 {
if _, writeErr := dst.Write(buffer[:n]); writeErr != nil {
return writeErr
}
downloaded += int64(n)
if onProgress != nil {
onProgress(downloaded, total)
}
}
if readErr == io.EOF {
if onProgress != nil {
onProgress(downloaded, total)
}
return nil
}
if readErr != nil {
return readErr
}
}
}
func (s *Service) setInstallProgress(stage string, progress int, lastError string) {
if progress < 0 {
progress = 0
}
if progress > 100 {
progress = 100
}
s.runtime.UpdateInstallStatus(func(status *RuntimeStatus) {
status.Ready = false
status.Installed = false
status.Installing = true
status.InstallProgress = progress
status.InstallStage = stage
status.Status = "installing"
status.LastError = lastError
status.CheckedAt = time.Now()
})
}
func (s *Service) finishInstallFailure(status string, message string) {
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: false,
Installing: false,
InstallProgress: 0,
InstallStage: status,
InstallPath: picoclawBinaryPath,
Status: status,
LastError: message,
CheckedAt: time.Now(),
})
}
func installFailureStatus(err error) string {
if err == nil {
return "install_failed"
}
if errors.Is(err, context.DeadlineExceeded) {
return "install_timeout"
}
return "install_failed"
}
func parseSHA512Digest(raw string, expectedName string) (string, error) {
lines := strings.Split(raw, "\n")
var fallback string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
fields := strings.Fields(trimmed)
if len(fields) == 0 {
continue
}
digest := fields[0]
if !isValidSHA512Digest(digest) {
continue
}
if len(fields) == 1 {
if fallback == "" {
fallback = strings.ToLower(digest)
}
continue
}
name := strings.TrimPrefix(fields[len(fields)-1], "*")
if expectedName == "" || name == expectedName {
return strings.ToLower(digest), nil
}
}
if fallback != "" {
return fallback, nil
}
return "", fmt.Errorf("failed to parse sha512 digest from checksum file")
}
func isValidSHA512Digest(value string) bool {
if len(value) != sha512.Size*2 {
return false
}
_, err := hex.DecodeString(value)
return err == nil
}
func verifyFileSHA512(filePath string, expectedDigest string) error {
expectedDigest = strings.ToLower(strings.TrimSpace(expectedDigest))
if !isValidSHA512Digest(expectedDigest) {
return fmt.Errorf("invalid expected sha512 digest")
}
file, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("failed to open file for sha512 verification: %w", err)
}
defer file.Close()
hasher := sha512.New()
if _, err := io.Copy(hasher, file); err != nil {
return fmt.Errorf("failed to hash file for sha512 verification: %w", err)
}
actualDigest := hex.EncodeToString(hasher.Sum(nil))
if actualDigest != expectedDigest {
return fmt.Errorf("sha512 mismatch: got %s", actualDigest)
}
return nil
}
func extractPicoclawBinary(archivePath string, destinationDir string) (string, error) {
file, err := os.Open(archivePath)
if err != nil {
return "", fmt.Errorf("failed to open archive: %w", err)
}
defer file.Close()
gzipReader, err := gzip.NewReader(file)
if err != nil {
return "", fmt.Errorf("failed to read archive: %w", err)
}
defer gzipReader.Close()
tarReader := tar.NewReader(gzipReader)
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return "", fmt.Errorf("failed to extract archive: %w", err)
}
if header.Typeflag != tar.TypeReg {
continue
}
if filepath.Base(header.Name) != "picoclaw" {
continue
}
extractedPath := filepath.Join(destinationDir, "picoclaw")
outFile, err := os.OpenFile(extractedPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
if err != nil {
return "", fmt.Errorf("failed to create extracted binary: %w", err)
}
if _, err := io.Copy(outFile, tarReader); err != nil {
_ = outFile.Close()
return "", fmt.Errorf("failed to extract picoclaw binary: %w", err)
}
if err := outFile.Close(); err != nil {
return "", fmt.Errorf("failed to finalize extracted binary: %w", err)
}
return extractedPath, nil
}
return "", fmt.Errorf("picoclaw binary not found in archive")
}
func installPicoclawBinary(source string, destination string) error {
inFile, err := os.Open(source)
if err != nil {
return fmt.Errorf("failed to open extracted picoclaw binary: %w", err)
}
defer inFile.Close()
tempDestination := destination + ".tmp"
outFile, err := os.OpenFile(tempDestination, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
if err != nil {
return fmt.Errorf("failed to create destination binary: %w", err)
}
if _, err := io.Copy(outFile, inFile); err != nil {
_ = outFile.Close()
_ = os.Remove(tempDestination)
return fmt.Errorf("failed to write destination binary: %w", err)
}
if err := outFile.Close(); err != nil {
_ = os.Remove(tempDestination)
return fmt.Errorf("failed to finalize destination binary: %w", err)
}
if err := os.Chmod(tempDestination, 0o755); err != nil {
_ = os.Remove(tempDestination)
return fmt.Errorf("failed to set destination mode: %w", err)
}
if err := os.Rename(tempDestination, destination); err != nil {
_ = os.Remove(tempDestination)
return fmt.Errorf("failed to install picoclaw binary: %w", err)
}
return nil
}

View File

@@ -0,0 +1,31 @@
package picoclaw
import (
"strings"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
func (s *Service) ReleaseRuntimeSession(c *gin.Context) {
sessionID := strings.TrimSpace(c.GetHeader(sessionIDHeader))
if sessionID == "" {
writePicoclawError(c, newPicoclawError(CodeSessionIDMissing, "missing X-PicoClaw-Session-ID"))
return
}
if session, ok := GetSessionManager().Get(sessionID); ok {
s.closeGatewaySession(session, websocket.CloseNormalClosure, "session released")
} else {
ReleaseSession(sessionID)
}
status := s.runtime.Get()
status.CurrentSession = s.lock.Owner()
s.runtime.Set(status)
writeSuccess(c, gin.H{
"released": true,
"current_session": status.CurrentSession,
})
}

View File

@@ -0,0 +1,228 @@
package picoclaw
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
func (s *Service) startRuntime() (string, string, *PicoclawError) {
if installed, statErr := isPicoclawInstalled(); statErr != nil {
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Installed = false
status.ModelConfigured = false
status.ModelName = ""
status.Status = "not_installed"
status.LastError = statErr.Error()
status.CheckedAt = time.Now()
})
return "", "", newPicoclawError(CodeRuntimeStartFailed, statErr.Error())
} else if !installed {
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Installed = false
status.ModelConfigured = false
status.ModelName = ""
status.Status = "not_installed"
status.LastError = "picoclaw is not installed"
status.CheckedAt = time.Now()
})
return "", "", newPicoclawError(CodeRuntimeStartFailed, "picoclaw is not installed")
}
scriptPath, err := resolvePicoclawStartScript()
if err != nil {
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Installed = true
status.Status = "unavailable"
status.LastError = err.Error()
status.CheckedAt = time.Now()
})
return "", "", newPicoclawError(CodeRuntimeStartFailed, err.Error())
}
if err := ensurePicoclawStartupDefaults(); err != nil {
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Installed = true
status.Status = "config_error"
status.ConfigError = err.Error()
status.LastError = err.Error()
status.CheckedAt = time.Now()
})
return "", "", newPicoclawError(CodeRuntimeStartFailed, err.Error())
}
command := scriptPath + " start"
ctx, cancel := context.WithTimeout(context.Background(), picoclawStartTimeout)
defer cancel()
output, execErr := exec.CommandContext(ctx, "sh", "-c", command).CombinedOutput()
trimmedOutput := strings.TrimSpace(string(output))
if execErr != nil {
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Installed = true
status.Status = "unavailable"
status.LastError = trimmedOutput
if status.LastError == "" {
status.LastError = execErr.Error()
}
status.CheckedAt = time.Now()
})
return command, trimmedOutput, newPicoclawError(CodeRuntimeStartFailed, "failed to start picoclaw runtime")
}
time.Sleep(picoclawStartWaitPeriod)
if runtimeErr := s.waitForRuntimeReady(picoclawStartTimeout); runtimeErr != nil {
startErr := newPicoclawError(CodeRuntimeStartFailed, runtimeErr.Message)
return command, trimmedOutput, startErr
}
return command, trimmedOutput, nil
}
func (s *Service) stopRuntime() (string, string, *PicoclawError) {
settings, _ := loadPicoclawGatewaySettings()
scriptPath, err := resolvePicoclawStartScript()
if err != nil {
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
InstallPath: picoclawBinaryPath,
ModelConfigured: settings.ModelConfigured,
ModelName: settings.ModelName,
Status: "unavailable",
LastError: err.Error(),
CheckedAt: time.Now(),
})
return "", "", newPicoclawError(CodeRuntimeStartFailed, err.Error())
}
command := scriptPath + " stop"
ctx, cancel := context.WithTimeout(context.Background(), picoclawStopTimeout)
defer cancel()
output, execErr := exec.CommandContext(ctx, "sh", "-c", command).CombinedOutput()
trimmedOutput := strings.TrimSpace(string(output))
if execErr != nil {
status := RuntimeStatus{
Ready: false,
Installed: true,
InstallPath: picoclawBinaryPath,
ModelConfigured: settings.ModelConfigured,
ModelName: settings.ModelName,
Status: "error",
LastError: trimmedOutput,
CheckedAt: time.Now(),
}
if trimmedOutput == "" {
status.LastError = execErr.Error()
}
s.runtime.Set(status)
return command, trimmedOutput, newPicoclawError(CodeRuntimeStartFailed, "failed to stop picoclaw runtime")
}
time.Sleep(picoclawStopWaitPeriod)
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
InstallPath: picoclawBinaryPath,
ModelConfigured: settings.ModelConfigured,
ModelName: settings.ModelName,
Status: "stopped",
CheckedAt: time.Now(),
CurrentSession: "",
})
return command, trimmedOutput, nil
}
func (s *Service) waitForRuntimeReady(timeout time.Duration) *PicoclawError {
deadline := time.Now().Add(timeout)
var lastErr *PicoclawError
for {
if runtimeErr := s.ensureRuntimeReady(); runtimeErr == nil {
return nil
} else {
lastErr = runtimeErr
}
if time.Now().After(deadline) {
break
}
time.Sleep(picoclawReadyPollPeriod)
}
if lastErr != nil {
return lastErr
}
return newPicoclawError(CodeRuntimeUnavailable, "gateway is unavailable")
}
func resolvePicoclawStartScript() (string, error) {
if _, err := os.Stat(etcInitPicoclawScript); err == nil {
return etcInitPicoclawScript, nil
}
if _, err := os.Stat(kvmappPicoclawScript); err == nil {
return kvmappPicoclawScript, nil
}
return "", fmt.Errorf("picoclaw start script not found: %s or %s", etcInitPicoclawScript, kvmappPicoclawScript)
}
func runPicoclawOnboard() (string, *PicoclawError) {
scriptPath, err := resolvePicoclawStartScript()
if err != nil {
return "", newPicoclawError(CodeRuntimeUnavailable, err.Error())
}
command := scriptPath + " onboard"
ctx, cancel := context.WithTimeout(context.Background(), picoclawOnboardTimeout)
defer cancel()
output, execErr := exec.CommandContext(ctx, "sh", "-c", command).CombinedOutput()
trimmedOutput := strings.TrimSpace(string(output))
if execErr != nil {
if trimmedOutput == "" {
trimmedOutput = execErr.Error()
}
return trimmedOutput, newPicoclawError(CodeRuntimeUnavailable, "failed to initialize picoclaw config")
}
return trimmedOutput, nil
}
func isPicoclawInstalled() (bool, error) {
info, err := os.Stat(picoclawBinaryPath)
if err == nil {
return !info.IsDir(), nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
func isRuntimeRunning() (bool, error) {
binName := filepath.Base(picoclawBinaryPath)
if binName == "" || binName == "." || binName == string(filepath.Separator) {
return false, fmt.Errorf("invalid picoclaw binary path: %s", picoclawBinaryPath)
}
command := exec.Command("pidof", binName)
if err := command.Run(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
return false, nil
}
return false, err
}
return true, nil
}

View File

@@ -0,0 +1,173 @@
package picoclaw
import (
"encoding/base64"
"net/http"
"time"
"NanoKVM-Server/common"
"NanoKVM-Server/service/stream/mjpeg"
"github.com/gin-gonic/gin"
)
var screenshotRetryDelay = 100 * time.Millisecond
const (
screenshotRetryCount = 3
cachedFrameMaxAge = 2 * time.Second
defaultPicoclawScreenshotWidth = 960
defaultPicoclawScreenshotHeight = 540
defaultPicoclawScreenshotQuality = 60
)
func (s *Service) Screenshot(c *gin.Context) {
var query ScreenshotQuery
if err := c.ShouldBindQuery(&query); err != nil {
writePicoclawError(c, newPicoclawError(CodeInvalidAction, "invalid screenshot query"))
return
}
sessionID, sessionErr := s.requireSessionID(c)
if sessionErr != nil {
writePicoclawError(c, sessionErr)
return
}
releaseAfter, lockErr := s.lock.AcquireTemporary(sessionID)
if lockErr != nil {
writePicoclawError(c, lockErr)
return
}
if releaseAfter {
defer s.lock.Release(sessionID)
}
data, meta, err := s.captureScreenshot(query)
if err != nil {
writePicoclawError(c, err)
return
}
if query.Format == "base64" {
meta.ImageBase64 = base64.StdEncoding.EncodeToString(data)
writeSuccess(c, meta)
return
}
c.Data(http.StatusOK, "image/jpeg", data)
}
func (s *Service) captureScreenshot(query ScreenshotQuery) ([]byte, ScreenshotMeta, *PicoclawError) {
width, height, quality := resolveScreenshotRequest(query)
if canUseCachedFrame(query) {
if frame, ok := mjpeg.GetLatestFrame(); ok && time.Since(frame.CapturedAt) <= cachedFrameMaxAge {
return frame.Data, ScreenshotMeta{
SourceWidth: frame.Width,
SourceHeight: frame.Height,
CaptureWidth: frame.Width,
CaptureHeight: frame.Height,
Format: "jpeg",
}, nil
}
}
screen := common.GetScreen()
common.CheckScreen()
for attempt := 0; attempt < screenshotRetryCount; attempt++ {
data, result := s.vision.ReadMjpeg(width, height, quality)
switch {
case result == 5:
if attempt < screenshotRetryCount-1 {
time.Sleep(screenshotRetryDelay)
continue
}
return nil, ScreenshotMeta{}, newPicoclawError(CodeScreenshotNoSignal, "no HDMI signal or frame unavailable")
case result < 0 || len(data) == 0:
return nil, ScreenshotMeta{}, newPicoclawError(CodeScreenshotFailed, "failed to capture screenshot")
default:
return data, ScreenshotMeta{
SourceWidth: screen.Width,
SourceHeight: screen.Height,
CaptureWidth: width,
CaptureHeight: height,
Format: "jpeg",
}, nil
}
}
return nil, ScreenshotMeta{}, newPicoclawError(CodeScreenshotFailed, "failed to capture screenshot")
}
func canUseCachedFrame(query ScreenshotQuery) bool {
return query.Width == 0 && query.Height == 0 && query.Quality == 0
}
func resolveScreenshotRequest(query ScreenshotQuery) (uint16, uint16, uint16) {
screen := common.GetScreen()
width := screen.Width
height := screen.Height
quality := screen.Quality
if query.Format == "base64" {
width, height = fitWithinBounds(width, height, defaultPicoclawScreenshotWidth, defaultPicoclawScreenshotHeight)
if quality == 0 || quality > defaultPicoclawScreenshotQuality {
quality = defaultPicoclawScreenshotQuality
}
}
width, height = applyRequestedDimensions(width, height, query.Width, query.Height)
if query.Quality > 0 {
quality = query.Quality
}
return width, height, quality
}
func applyRequestedDimensions(defaultWidth uint16, defaultHeight uint16, requestedWidth uint16, requestedHeight uint16) (uint16, uint16) {
switch {
case requestedWidth > 0 && requestedHeight > 0:
return requestedWidth, requestedHeight
case requestedWidth > 0:
return fitWithinBounds(defaultWidth, defaultHeight, requestedWidth, 0)
case requestedHeight > 0:
return fitWithinBounds(defaultWidth, defaultHeight, 0, requestedHeight)
default:
return defaultWidth, defaultHeight
}
}
func fitWithinBounds(sourceWidth uint16, sourceHeight uint16, maxWidth uint16, maxHeight uint16) (uint16, uint16) {
if sourceWidth == 0 || sourceHeight == 0 {
return sourceWidth, sourceHeight
}
if maxWidth == 0 && maxHeight == 0 {
return sourceWidth, sourceHeight
}
width := int(sourceWidth)
height := int(sourceHeight)
limitedWidth := width
limitedHeight := height
if maxWidth > 0 && limitedWidth > int(maxWidth) {
limitedWidth = int(maxWidth)
limitedHeight = height * limitedWidth / width
}
if maxHeight > 0 && limitedHeight > int(maxHeight) {
limitedHeight = int(maxHeight)
limitedWidth = width * limitedHeight / height
}
if limitedWidth <= 0 {
limitedWidth = 1
}
if limitedHeight <= 0 {
limitedHeight = 1
}
return uint16(limitedWidth), uint16(limitedHeight)
}

View File

@@ -0,0 +1,385 @@
package picoclaw
import (
"net"
"net/url"
"os"
"sync"
"time"
"NanoKVM-Server/common"
"NanoKVM-Server/service/hid"
"github.com/gin-gonic/gin"
)
const sessionIDHeader = "X-PicoClaw-Session-ID"
var (
configStoreOnce sync.Once
configStore *ConfigStore
runtimeStoreOnce sync.Once
runtimeStore *RuntimeStore
probeLoopOnce sync.Once
)
const runtimeStatusRefreshInterval = 2 * time.Second
func NewService() *Service {
return &Service{
vision: common.GetKvmVision(),
hid: hid.GetHid(),
config: getConfigStore(),
lock: GetSessionLock(),
runtime: getRuntimeStore(),
}
}
func getConfigStore() *ConfigStore {
configStoreOnce.Do(func() {
configStore = &ConfigStore{
config: defaultConfig(),
}
})
return configStore
}
func getRuntimeStore() *RuntimeStore {
runtimeStoreOnce.Do(func() {
runtimeStore = &RuntimeStore{
status: RuntimeStatus{
Ready: false,
Installed: false,
Installing: false,
InstallProgress: 0,
InstallPath: picoclawBinaryPath,
ModelConfigured: false,
Status: "checking",
},
}
})
return runtimeStore
}
func (s *Service) GetRuntimeStatus(c *gin.Context) {
s.startRuntimeProbeLoop()
status := s.runtime.Get()
if shouldRefreshRuntimeStatus(status) {
_ = s.ensureRuntimeReady()
status = s.runtime.Get()
}
if installed, ok := picoclawInstalledState(); ok {
status.Installed = installed
s.runtime.Update(func(current *RuntimeStatus) {
current.Installed = installed
})
}
writeSuccess(c, withAgentProfile(status))
}
func (s *Service) GetRuntimeSession(c *gin.Context) {
writeSuccess(c, gin.H{
"current_session": s.lock.Owner(),
"checked_at": time.Now(),
})
}
func (s *Service) requireSessionID(c *gin.Context) (string, *PicoclawError) {
sessionID := c.GetHeader(sessionIDHeader)
if sessionID == "" {
return "", newPicoclawError(CodeSessionIDMissing, "missing X-PicoClaw-Session-ID")
}
return sessionID, nil
}
func (s *ConfigStore) Get() Config {
s.mu.RLock()
defer s.mu.RUnlock()
return s.config
}
func (s *ConfigStore) Set(cfg Config) {
s.mu.Lock()
defer s.mu.Unlock()
s.config = cfg
}
func (s *RuntimeStore) Get() RuntimeStatus {
s.mu.RLock()
defer s.mu.RUnlock()
return s.status
}
func (s *RuntimeStore) Set(status RuntimeStatus) {
s.mu.Lock()
defer s.mu.Unlock()
status.InstallPath = picoclawBinaryPath
s.status = status
}
func (s *RuntimeStore) Update(update func(*RuntimeStatus)) {
s.mu.Lock()
defer s.mu.Unlock()
update(&s.status)
s.status.InstallPath = picoclawBinaryPath
}
func (s *RuntimeStore) UpdateInstallStatus(update func(*RuntimeStatus)) {
s.mu.Lock()
defer s.mu.Unlock()
update(&s.status)
s.status.InstallPath = picoclawBinaryPath
}
func (s *Service) ensureRuntimeReady() *PicoclawError {
currentStatus := s.runtime.Get()
if currentStatus.Installing {
return newPicoclawError(CodeRuntimeUnavailable, "picoclaw installation is in progress")
}
installed, statErr := isPicoclawInstalled()
if statErr != nil {
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: false,
Installing: false,
InstallProgress: 0,
InstallPath: picoclawBinaryPath,
Status: "not_installed",
LastError: statErr.Error(),
CheckedAt: time.Now(),
CurrentSession: s.lock.Owner(),
})
return newPicoclawError(CodeRuntimeUnavailable, "failed to check picoclaw installation")
}
if !installed {
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: false,
Installing: false,
InstallProgress: 0,
InstallPath: picoclawBinaryPath,
ModelConfigured: false,
Status: "not_installed",
CheckedAt: time.Now(),
CurrentSession: s.lock.Owner(),
})
return newPicoclawError(CodeRuntimeUnavailable, "picoclaw is not installed")
}
configPath, pathErr := resolvePicoclawConfigPath()
if pathErr != nil {
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
Installing: false,
InstallProgress: 0,
InstallPath: picoclawBinaryPath,
ModelConfigured: false,
Status: "model_not_configured",
LastError: pathErr.Error(),
CheckedAt: time.Now(),
CurrentSession: s.lock.Owner(),
})
return newPicoclawError(CodeRuntimeUnavailable, "picoclaw model is not configured")
}
if _, err := os.Stat(configPath); err != nil {
if os.IsNotExist(err) {
if _, onboardErr := runPicoclawOnboard(); onboardErr != nil {
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
Installing: false,
InstallProgress: 0,
InstallPath: picoclawBinaryPath,
ModelConfigured: false,
Status: "model_not_configured",
LastError: onboardErr.Message,
CheckedAt: time.Now(),
CurrentSession: s.lock.Owner(),
})
return newPicoclawError(CodeRuntimeUnavailable, "picoclaw model is not configured")
}
if _, statErr := os.Stat(configPath); statErr == nil {
goto configReady
}
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
Installing: false,
InstallProgress: 0,
InstallPath: picoclawBinaryPath,
ModelConfigured: false,
Status: "model_not_configured",
CheckedAt: time.Now(),
CurrentSession: s.lock.Owner(),
})
return newPicoclawError(CodeRuntimeUnavailable, "picoclaw model is not configured")
}
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
Installing: false,
InstallProgress: 0,
InstallPath: picoclawBinaryPath,
ModelConfigured: false,
Status: "config_error",
ConfigError: err.Error(),
LastError: err.Error(),
CheckedAt: time.Now(),
CurrentSession: s.lock.Owner(),
})
return newPicoclawError(CodeRuntimeUnavailable, "gateway config is invalid")
}
configReady:
if syncErr := s.syncConfigFromPicoclaw(); syncErr != nil {
return syncErr
}
settings, settingsErr := loadPicoclawGatewaySettings()
if settingsErr != nil {
return newPicoclawError(CodeRuntimeUnavailable, settingsErr.Error())
}
if !settings.ModelConfigured {
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
Installing: false,
InstallProgress: 0,
InstallPath: picoclawBinaryPath,
ModelConfigured: false,
ModelName: settings.TargetModelName,
Status: "model_not_configured",
CheckedAt: time.Now(),
CurrentSession: s.lock.Owner(),
})
return newPicoclawError(CodeRuntimeUnavailable, "picoclaw model is not configured")
}
cfg := s.config.Get()
parsed, err := url.Parse(cfg.GatewayURL)
if err != nil {
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
Installing: false,
InstallProgress: 0,
InstallPath: picoclawBinaryPath,
ModelConfigured: true,
ModelName: settings.ModelName,
Status: "config_error",
ConfigError: "invalid gateway url",
LastError: err.Error(),
CheckedAt: time.Now(),
CurrentSession: s.lock.Owner(),
})
return newPicoclawError(CodeRuntimeUnavailable, "gateway config is invalid")
}
if parsed.Scheme != "ws" && parsed.Scheme != "wss" {
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
Installing: false,
InstallProgress: 0,
InstallPath: picoclawBinaryPath,
ModelConfigured: true,
ModelName: settings.ModelName,
Status: "config_error",
ConfigError: "invalid gateway url scheme",
LastError: parsed.Scheme,
CheckedAt: time.Now(),
CurrentSession: s.lock.Owner(),
})
return newPicoclawError(CodeRuntimeUnavailable, "gateway config is invalid")
}
timeout := time.Duration(cfg.ConnectTimeoutMs) * time.Millisecond
hostPort, err := gatewayHostPort(parsed)
if err != nil {
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
Installing: false,
InstallProgress: 0,
InstallPath: picoclawBinaryPath,
ModelConfigured: true,
ModelName: settings.ModelName,
Status: "config_error",
ConfigError: err.Error(),
LastError: err.Error(),
CheckedAt: time.Now(),
CurrentSession: s.lock.Owner(),
})
return newPicoclawError(CodeRuntimeUnavailable, "gateway config is invalid")
}
conn, err := net.DialTimeout("tcp", hostPort, timeout)
if err != nil {
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
Installing: false,
InstallProgress: 0,
InstallPath: picoclawBinaryPath,
ModelConfigured: true,
ModelName: settings.ModelName,
Status: "unavailable",
LastError: err.Error(),
CheckedAt: time.Now(),
CurrentSession: s.lock.Owner(),
})
return newPicoclawError(CodeRuntimeUnavailable, "gateway is unavailable")
}
_ = conn.Close()
s.runtime.Set(RuntimeStatus{
Ready: true,
Installed: true,
Installing: false,
InstallProgress: 100,
InstallPath: picoclawBinaryPath,
ModelConfigured: true,
ModelName: settings.ModelName,
Status: "ready",
CheckedAt: time.Now(),
CurrentSession: s.lock.Owner(),
})
return nil
}
func (s *Service) startRuntimeProbeLoop() {
probeLoopOnce.Do(func() {
go func() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
_ = s.ensureRuntimeReady()
}
}()
})
}
func shouldRefreshRuntimeStatus(status RuntimeStatus) bool {
if status.Installing {
return false
}
if status.CheckedAt.IsZero() {
return true
}
return time.Since(status.CheckedAt) >= runtimeStatusRefreshInterval
}
func picoclawInstalledState() (bool, bool) {
installed, err := isPicoclawInstalled()
if err != nil {
return false, false
}
return installed, true
}

View File

@@ -0,0 +1,52 @@
package picoclaw
import (
"os"
"path/filepath"
"NanoKVM-Server/service/hid"
log "github.com/sirupsen/logrus"
)
const picoclawMediaTempDirName = "picoclaw_media"
func ReleaseSession(sessionID string) {
GetSessionLock().Release(sessionID)
releaseAllHIDState()
}
func releaseAllHIDState() {
h := hid.GetHid()
h.WriteHid0([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00})
h.WriteHid1([]byte{0x00, 0x00, 0x00, 0x00})
h.WriteHid2([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00})
}
func (s *Service) releaseAllHIDState() {
s.hid.WriteHid0([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00})
s.hid.WriteHid1([]byte{0x00, 0x00, 0x00, 0x00})
s.hid.WriteHid2([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00})
}
func picoclawMediaTempDir() string {
return filepath.Join(os.TempDir(), picoclawMediaTempDirName)
}
func cleanupPicoclawMediaTempDir() {
mediaDir := picoclawMediaTempDir()
if _, err := os.Stat(mediaDir); err != nil {
if !os.IsNotExist(err) {
log.Warnf("failed to stat picoclaw media directory %s: %v", mediaDir, err)
}
return
}
if err := os.RemoveAll(mediaDir); err != nil {
log.Warnf("failed to remove picoclaw media directory %s: %v", mediaDir, err)
return
}
log.Infof("removed picoclaw media directory %s", mediaDir)
}

View File

@@ -0,0 +1,543 @@
package picoclaw
import (
"bufio"
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
const (
picoSessionPrefix = "agent:main:pico:direct:pico:"
sanitizedPicoSessionPrefix = "agent_main_pico_direct_pico_"
opaqueSessionKeyPrefix = "sk_v1_"
maxSessionJSONLLineSize = 10 * 1024 * 1024
maxSessionPreviewRunes = 60
defaultSessionLimit = 20
)
type sessionStoredMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type sessionStoredFile struct {
Key string `json:"key"`
Messages []sessionStoredMessage `json:"messages"`
Summary string `json:"summary,omitempty"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
}
type sessionMetaFile struct {
Key string `json:"key"`
Summary string `json:"summary"`
Skip int `json:"skip"`
Count int `json:"count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type sessionListItem struct {
ID string `json:"id"`
Title string `json:"title"`
Preview string `json:"preview"`
MessageCount int `json:"message_count"`
Created string `json:"created"`
Updated string `json:"updated"`
}
type sessionDetailMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type sessionDetail struct {
ID string `json:"id"`
Messages []sessionDetailMessage `json:"messages"`
Summary string `json:"summary,omitempty"`
Created string `json:"created"`
Updated string `json:"updated"`
}
func resolvePicoclawWorkspacePath() (string, error) {
configPath, err := resolvePicoclawConfigPath()
if err != nil {
return "", err
}
if doc, err := loadPicoclawConfigDocument(); err == nil {
if workspace := expandPicoclawPath(doc.config.Agents.Defaults.Workspace); workspace != "" {
return workspace, nil
}
}
return filepath.Join(filepath.Dir(configPath), "workspace"), nil
}
func resolvePicoclawSessionsPath() (string, error) {
workspacePath, err := resolvePicoclawWorkspacePath()
if err != nil {
return "", err
}
return filepath.Join(workspacePath, "sessions"), nil
}
func sanitizeSessionKey(key string) string {
return strings.ReplaceAll(key, ":", "_")
}
func extractPicoSessionID(key string) (string, bool) {
if strings.HasPrefix(key, picoSessionPrefix) {
return strings.TrimPrefix(key, picoSessionPrefix), true
}
return "", false
}
func extractPicoSessionIDFromSanitizedKey(key string) (string, bool) {
if strings.HasPrefix(key, sanitizedPicoSessionPrefix) {
return strings.TrimPrefix(key, sanitizedPicoSessionPrefix), true
}
return "", false
}
func isOpaqueSessionKey(key string) bool {
return strings.HasPrefix(key, opaqueSessionKeyPrefix)
}
func extractOpaqueSessionID(fileName string) (string, bool) {
base := strings.TrimSuffix(fileName, filepath.Ext(fileName))
if strings.HasSuffix(fileName, ".meta.json") {
base = strings.TrimSuffix(fileName, ".meta.json")
}
if isOpaqueSessionKey(base) {
return base, true
}
return "", false
}
func (s *Service) ListSessions(c *gin.Context) {
dir, err := resolvePicoclawSessionsPath()
if err != nil {
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "failed to resolve sessions directory"))
return
}
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
writeSuccess(c, []sessionListItem{})
return
}
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "failed to read sessions directory"))
return
}
items := make([]sessionListItem, 0)
seen := make(map[string]struct{})
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
var (
sessionID string
sess sessionStoredFile
loadErr error
ok bool
)
switch {
case strings.HasSuffix(name, ".jsonl"):
baseName := strings.TrimSuffix(name, ".jsonl")
if opaqueID, found := extractOpaqueSessionID(name); found {
sessionID = opaqueID
sess, loadErr = readOpaqueJSONLSession(dir, sessionID)
} else {
sessionID, ok = extractPicoSessionIDFromSanitizedKey(baseName)
if !ok {
continue
}
sess, loadErr = readJSONLSession(dir, sessionID)
}
if loadErr == nil && isEmptySession(sess) {
continue
}
case strings.HasSuffix(name, ".meta.json"):
continue
case strings.HasSuffix(name, ".json"):
base := strings.TrimSuffix(name, ".json")
if _, statErr := os.Stat(filepath.Join(dir, base+".jsonl")); statErr == nil {
if jsonlSessionID, found := extractPicoSessionIDFromSanitizedKey(base); found {
if jsonlSess, jsonlErr := readJSONLSession(dir, jsonlSessionID); jsonlErr == nil && !isEmptySession(jsonlSess) {
continue
}
}
}
sess, loadErr = readLegacySession(dir, name)
if loadErr == nil && isEmptySession(sess) {
continue
}
if loadErr == nil {
sessionID, ok = extractPicoSessionID(sess.Key)
if !ok {
continue
}
}
default:
continue
}
if loadErr != nil {
continue
}
if _, exists := seen[sessionID]; exists {
continue
}
seen[sessionID] = struct{}{}
items = append(items, buildSessionListItem(sessionID, sess))
}
sort.Slice(items, func(i, j int) bool {
return items[i].Updated > items[j].Updated
})
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", strconv.Itoa(defaultSessionLimit)))
if offset < 0 {
offset = 0
}
if limit <= 0 {
limit = defaultSessionLimit
}
if offset >= len(items) {
writeSuccess(c, []sessionListItem{})
return
}
end := offset + limit
if end > len(items) {
end = len(items)
}
writeSuccess(c, items[offset:end])
}
func (s *Service) GetSession(c *gin.Context) {
sessionID := strings.TrimSpace(c.Param("id"))
if sessionID == "" {
writePicoclawError(c, newPicoclawError(CodeInvalidAction, "missing session id"))
return
}
dir, err := resolvePicoclawSessionsPath()
if err != nil {
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "failed to resolve sessions directory"))
return
}
var sess sessionStoredFile
if isOpaqueSessionKey(sessionID) {
sess, err = readOpaqueJSONLSession(dir, sessionID)
} else {
sess, err = readJSONLSession(dir, sessionID)
}
if err == nil && isEmptySession(sess) {
err = os.ErrNotExist
}
if err != nil {
if errors.Is(err, os.ErrNotExist) && !isOpaqueSessionKey(sessionID) {
sess, err = readLegacySession(dir, sanitizeSessionKey(picoSessionPrefix+sessionID)+".json")
if err == nil && isEmptySession(sess) {
err = os.ErrNotExist
}
}
if err != nil {
sessionErr := newPicoclawError(CodeRuntimeUnavailable, "session not found")
sessionErr.StatusCode = http.StatusNotFound
writePicoclawError(c, sessionErr)
return
}
}
messages := make([]sessionDetailMessage, 0, len(sess.Messages))
for _, msg := range sess.Messages {
if (msg.Role == "user" || msg.Role == "assistant") && strings.TrimSpace(msg.Content) != "" {
messages = append(messages, sessionDetailMessage{
Role: msg.Role,
Content: msg.Content,
})
}
}
writeSuccess(c, sessionDetail{
ID: sessionID,
Messages: messages,
Summary: sess.Summary,
Created: sess.Created.Format(time.RFC3339),
Updated: sess.Updated.Format(time.RFC3339),
})
}
func (s *Service) DeleteSession(c *gin.Context) {
sessionID := strings.TrimSpace(c.Param("id"))
if sessionID == "" {
writePicoclawError(c, newPicoclawError(CodeInvalidAction, "missing session id"))
return
}
dir, err := resolvePicoclawSessionsPath()
if err != nil {
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "failed to resolve sessions directory"))
return
}
var base string
if isOpaqueSessionKey(sessionID) {
base = filepath.Join(dir, sessionID)
} else {
base = filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+sessionID))
}
paths := []string{base + ".jsonl", base + ".meta.json", base + ".json"}
removed := false
for _, path := range paths {
if err := os.Remove(path); err != nil {
if os.IsNotExist(err) {
continue
}
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "failed to delete session"))
return
}
removed = true
}
if !removed {
sessionErr := newPicoclawError(CodeRuntimeUnavailable, "session not found")
sessionErr.StatusCode = http.StatusNotFound
writePicoclawError(c, sessionErr)
return
}
writeSuccess(c, gin.H{
"id": sessionID,
"deleted": true,
})
}
func readLegacySession(dir, fileName string) (sessionStoredFile, error) {
data, err := os.ReadFile(filepath.Join(dir, fileName))
if err != nil {
return sessionStoredFile{}, err
}
var sess sessionStoredFile
if err := json.Unmarshal(data, &sess); err != nil {
return sessionStoredFile{}, err
}
return sess, nil
}
func readSessionMeta(path, sessionKey string) (sessionMetaFile, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return sessionMetaFile{Key: sessionKey}, nil
}
if err != nil {
return sessionMetaFile{}, err
}
var meta sessionMetaFile
if err := json.Unmarshal(data, &meta); err != nil {
return sessionMetaFile{}, err
}
if meta.Key == "" {
meta.Key = sessionKey
}
return meta, nil
}
func readSessionMessages(path string, skip int) ([]sessionStoredMessage, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
messages := make([]sessionStoredMessage, 0)
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 64*1024), maxSessionJSONLLineSize)
seen := 0
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
seen++
if seen <= skip {
continue
}
var msg sessionStoredMessage
if err := json.Unmarshal(line, &msg); err != nil {
continue
}
messages = append(messages, msg)
}
if err := scanner.Err(); err != nil {
return nil, err
}
return messages, nil
}
func readOpaqueJSONLSession(dir, opaqueKey string) (sessionStoredFile, error) {
base := filepath.Join(dir, opaqueKey)
jsonlPath := base + ".jsonl"
metaPath := base + ".meta.json"
meta, err := readSessionMeta(metaPath, opaqueKey)
if err != nil {
return sessionStoredFile{}, err
}
messages, err := readSessionMessages(jsonlPath, meta.Skip)
if err != nil {
return sessionStoredFile{}, err
}
created := meta.CreatedAt
updated := meta.UpdatedAt
if created.IsZero() || updated.IsZero() {
if info, statErr := os.Stat(jsonlPath); statErr == nil {
if created.IsZero() {
created = info.ModTime()
}
if updated.IsZero() {
updated = info.ModTime()
}
}
}
return sessionStoredFile{
Key: meta.Key,
Messages: messages,
Summary: meta.Summary,
Created: created,
Updated: updated,
}, nil
}
func readJSONLSession(dir, sessionID string) (sessionStoredFile, error) {
sessionKey := picoSessionPrefix + sessionID
base := filepath.Join(dir, sanitizeSessionKey(sessionKey))
jsonlPath := base + ".jsonl"
metaPath := base + ".meta.json"
meta, err := readSessionMeta(metaPath, sessionKey)
if err != nil {
return sessionStoredFile{}, err
}
messages, err := readSessionMessages(jsonlPath, meta.Skip)
if err != nil {
return sessionStoredFile{}, err
}
created := meta.CreatedAt
updated := meta.UpdatedAt
if created.IsZero() || updated.IsZero() {
if info, statErr := os.Stat(jsonlPath); statErr == nil {
if created.IsZero() {
created = info.ModTime()
}
if updated.IsZero() {
updated = info.ModTime()
}
}
}
return sessionStoredFile{
Key: meta.Key,
Messages: messages,
Summary: meta.Summary,
Created: created,
Updated: updated,
}, nil
}
func buildSessionListItem(sessionID string, sess sessionStoredFile) sessionListItem {
preview := ""
for _, msg := range sess.Messages {
if msg.Role == "user" && strings.TrimSpace(msg.Content) != "" {
preview = msg.Content
break
}
}
title := strings.TrimSpace(sess.Summary)
if title == "" {
title = preview
}
title = truncateRunes(title, maxSessionPreviewRunes)
preview = truncateRunes(preview, maxSessionPreviewRunes)
if preview == "" {
preview = "(empty)"
}
if title == "" {
title = preview
}
messageCount := 0
for _, msg := range sess.Messages {
if (msg.Role == "user" || msg.Role == "assistant") && strings.TrimSpace(msg.Content) != "" {
messageCount++
}
}
return sessionListItem{
ID: sessionID,
Title: title,
Preview: preview,
MessageCount: messageCount,
Created: sess.Created.Format(time.RFC3339),
Updated: sess.Updated.Format(time.RFC3339),
}
}
func isEmptySession(sess sessionStoredFile) bool {
return len(sess.Messages) == 0 && strings.TrimSpace(sess.Summary) == ""
}
func truncateRunes(s string, maxLen int) string {
if maxLen <= 0 {
return ""
}
runes := []rune(strings.TrimSpace(s))
if len(runes) <= maxLen {
return string(runes)
}
return string(runes[:maxLen]) + "..."
}

View File

@@ -0,0 +1,135 @@
package picoclaw
import (
"sync"
"time"
)
var (
sessionLockOnce sync.Once
sessionLock *SessionLock
)
const sessionLockDuration = 30 * time.Minute
type SessionLock struct {
mu sync.Mutex
ownerSessionID string
acquiredAt time.Time
expiresAt time.Time
}
func GetSessionLock() *SessionLock {
sessionLockOnce.Do(func() {
sessionLock = &SessionLock{}
})
return sessionLock
}
func (l *SessionLock) Ensure(sessionID string) *PicoclawError {
_, err := l.acquire(sessionID)
return err
}
func (l *SessionLock) AcquireTemporary(sessionID string) (bool, *PicoclawError) {
return l.acquire(sessionID)
}
func (l *SessionLock) Renew(sessionID string) bool {
if sessionID == "" {
return false
}
l.mu.Lock()
defer l.mu.Unlock()
if l.ownerSessionID != sessionID {
return false
}
l.expiresAt = time.Now().Add(sessionLockDuration)
return true
}
func (l *SessionLock) clearExpiredLocked(now time.Time) {
if l.ownerSessionID == "" {
return
}
if l.expiresAt.IsZero() || now.Before(l.expiresAt) {
return
}
l.ownerSessionID = ""
l.acquiredAt = time.Time{}
l.expiresAt = time.Time{}
}
func (l *SessionLock) acquire(sessionID string) (bool, *PicoclawError) {
if sessionID == "" {
return false, newPicoclawError(CodeSessionIDInvalid, "invalid PicoClaw session")
}
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
l.clearExpiredLocked(now)
if l.ownerSessionID == "" || l.ownerSessionID == sessionID {
acquired := l.ownerSessionID == ""
l.ownerSessionID = sessionID
if l.acquiredAt.IsZero() {
l.acquiredAt = now
}
l.expiresAt = now.Add(sessionLockDuration)
return acquired, nil
}
err := newPicoclawError(CodePicoclawLockHeld, "another PicoClaw session is running")
err.SessionID = l.ownerSessionID
return false, err
}
func (l *SessionLock) Release(sessionID string) bool {
l.mu.Lock()
defer l.mu.Unlock()
if l.ownerSessionID == "" {
return true
}
if sessionID != "" && l.ownerSessionID != sessionID {
return false
}
l.ownerSessionID = ""
l.acquiredAt = time.Time{}
l.expiresAt = time.Time{}
return true
}
func (l *SessionLock) ForceTakeover(sessionID string) {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
l.ownerSessionID = sessionID
l.acquiredAt = now
l.expiresAt = now.Add(sessionLockDuration)
}
func (l *SessionLock) Owner() string {
l.mu.Lock()
defer l.mu.Unlock()
l.clearExpiredLocked(time.Now())
return l.ownerSessionID
}
func (l *SessionLock) BlocksManualInput() bool {
l.mu.Lock()
defer l.mu.Unlock()
l.clearExpiredLocked(time.Now())
return l.ownerSessionID != ""
}

View File

@@ -0,0 +1,94 @@
package picoclaw
import (
"sync"
"time"
"github.com/gorilla/websocket"
)
var (
sessionManagerOnce sync.Once
sessionManager *SessionManager
)
func GetSessionManager() *SessionManager {
sessionManagerOnce.Do(func() {
sessionManager = &SessionManager{
sessions: make(map[string]*GatewaySession),
}
})
return sessionManager
}
func (m *SessionManager) Register(sessionID string, downstream *websocket.Conn) (*GatewaySession, *PicoclawError) {
m.mu.Lock()
defer m.mu.Unlock()
if existing, ok := m.sessions[sessionID]; ok {
switch existing.State {
case SessionStateClosing, SessionStateClosed:
delete(m.sessions, sessionID)
default:
err := newPicoclawError(CodePicoclawLockHeld, "session is already connected")
err.SessionID = sessionID
return nil, err
}
}
now := time.Now()
session := &GatewaySession{
SessionID: sessionID,
State: SessionStateCreated,
Downstream: downstream,
CreatedAt: now,
UpdatedAt: now,
}
m.sessions[sessionID] = session
return session, nil
}
func (m *SessionManager) Get(sessionID string) (*GatewaySession, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
session, ok := m.sessions[sessionID]
return session, ok
}
func (m *SessionManager) SetState(sessionID string, state SessionState) {
m.mu.Lock()
defer m.mu.Unlock()
if session, ok := m.sessions[sessionID]; ok {
session.State = state
session.UpdatedAt = time.Now()
}
}
func (m *SessionManager) AttachUpstream(sessionID string, upstream *websocket.Conn) {
m.mu.Lock()
defer m.mu.Unlock()
if session, ok := m.sessions[sessionID]; ok {
session.Upstream = upstream
session.UpdatedAt = time.Now()
}
}
func (m *SessionManager) AttachDownstream(sessionID string, downstream *websocket.Conn) {
m.mu.Lock()
defer m.mu.Unlock()
if session, ok := m.sessions[sessionID]; ok {
session.Downstream = downstream
session.UpdatedAt = time.Now()
}
}
func (m *SessionManager) Remove(sessionID string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.sessions, sessionID)
}

View File

@@ -0,0 +1,217 @@
package picoclaw
import (
"encoding/json"
"reflect"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
)
type VisionReader interface {
ReadMjpeg(width uint16, height uint16, quality uint16) (data []byte, result int)
}
type HIDWriter interface {
WriteHid0(data []byte)
WriteHid1(data []byte)
WriteHid2(data []byte)
}
type Service struct {
vision VisionReader
hid HIDWriter
config *ConfigStore
lock *SessionLock
runtime *RuntimeStore
}
type ConfigStore struct {
mu sync.RWMutex
config Config
}
type RuntimeStore struct {
mu sync.RWMutex
status RuntimeStatus
}
type Config struct {
GatewayURL string `json:"gateway_url"`
ConnectTimeoutMs int `json:"connect_timeout_ms"`
ReadTimeoutMs int `json:"read_timeout_ms"`
WriteTimeoutMs int `json:"write_timeout_ms"`
PingIntervalMs int `json:"ping_interval_ms"`
MaxMessageBytes int `json:"max_message_bytes"`
AllowTokenQuery bool `json:"allow_token_query"`
Token string `json:"token,omitempty"`
}
type RuntimeStatus struct {
Ready bool `json:"ready"`
Installed bool `json:"installed"`
Installing bool `json:"installing"`
InstallProgress int `json:"install_progress,omitempty"`
InstallStage string `json:"install_stage,omitempty"`
InstallPath string `json:"install_path,omitempty"`
AgentProfile string `json:"agent_profile,omitempty"`
ModelConfigured bool `json:"model_configured"`
ModelName string `json:"model_name,omitempty"`
Status string `json:"status"`
ConfigError string `json:"config_error,omitempty"`
LastError string `json:"last_error,omitempty"`
CheckedAt time.Time `json:"checked_at,omitempty"`
CurrentSession string `json:"current_session,omitempty"`
}
type RuntimeStartResult struct {
Started bool `json:"started"`
Command string `json:"command"`
Output string `json:"output,omitempty"`
Status RuntimeStatus `json:"status"`
}
type RuntimeInstallResult struct {
Installed bool `json:"installed"`
Binary string `json:"binary"`
Download string `json:"download"`
Output string `json:"output,omitempty"`
Status RuntimeStatus `json:"status"`
}
type ScreenshotMeta struct {
ImageBase64 string `json:"image_base64,omitempty"`
SourceWidth uint16 `json:"source_width"`
SourceHeight uint16 `json:"source_height"`
CaptureWidth uint16 `json:"capture_width"`
CaptureHeight uint16 `json:"capture_height"`
Format string `json:"format"`
}
type ScreenshotQuery struct {
Format string `form:"format"`
Width uint16 `form:"width"`
Height uint16 `form:"height"`
Quality uint16 `form:"quality"`
}
type Point struct {
X *float64 `json:"x"`
Y *float64 `json:"y"`
}
type HotkeyKeys []string
func (k *HotkeyKeys) UnmarshalJSON(data []byte) error {
if string(data) == "null" {
*k = nil
return nil
}
var list []string
if err := json.Unmarshal(data, &list); err == nil {
*k = normalizeHotkeyKeys(list)
return nil
}
var csv string
if err := json.Unmarshal(data, &csv); err == nil {
if strings.TrimSpace(csv) == "" {
*k = nil
return nil
}
*k = normalizeHotkeyKeys(strings.Split(csv, ","))
return nil
}
return &json.UnmarshalTypeError{Value: "keys", Type: hotkeyKeysType}
}
var hotkeyKeysType = reflect.TypeOf("")
func normalizeHotkeyKeys(input []string) HotkeyKeys {
keys := make([]string, 0, len(input))
for _, item := range input {
trimmed := strings.TrimSpace(item)
if trimmed == "" {
continue
}
keys = append(keys, trimmed)
}
return HotkeyKeys(keys)
}
type Action struct {
Action string `json:"action"`
X *float64 `json:"x"`
Y *float64 `json:"y"`
From *Point `json:"from"`
To *Point `json:"to"`
Button string `json:"button"`
Text string `json:"text"`
Keys HotkeyKeys `json:"keys"`
Direction string `json:"direction"`
Amount int `json:"amount"`
DurationMs int `json:"duration_ms"`
}
type ActionBatch struct {
Actions []Action `json:"actions"`
}
type ActionResult struct {
Action string `json:"action"`
DurationMs int64 `json:"duration_ms"`
HIDWrites int `json:"hid_writes"`
ExecutedActions int `json:"executed_actions,omitempty"`
}
type cachedFrame struct {
data []byte
width uint16
height uint16
capturedAt time.Time
}
type SessionState string
const (
SessionStateCreated SessionState = "created"
SessionStateConnecting SessionState = "connecting"
SessionStateActive SessionState = "active"
SessionStateClosing SessionState = "closing"
SessionStateClosed SessionState = "closed"
)
const (
CloseCodePicoclawLockHeld = 4001
CloseCodeRuntimeUnavailable = 4002
CloseCodeAuthFailed = 4003
CloseCodePicoclawTakenOver = 4004
CloseCodeUpstreamClosed = 4005
)
type GatewaySession struct {
SessionID string
State SessionState
Downstream *websocket.Conn
Upstream *websocket.Conn
CreatedAt time.Time
UpdatedAt time.Time
closeOnce sync.Once
upstreamWriteMu sync.Mutex
downstreamWriteMu sync.Mutex
}
type SessionManager struct {
mu sync.RWMutex
sessions map[string]*GatewaySession
}
type LoadImageRequest struct {
Path string `json:"path"`
Prompt string `json:"prompt"`
Filename string `json:"filename"`
}

View File

@@ -0,0 +1,167 @@
package stream
import (
"sort"
"sync"
"time"
)
const (
CaptureStatusEvent = "capture-status"
CaptureModeDirect = "direct"
CaptureModeH264 = "h264"
CaptureModeMJPEG = "mjpeg"
CaptureSeverityError = "error"
CaptureSeverityWarning = "warning"
)
type CaptureStatus struct {
Ok bool `json:"ok"`
Result int `json:"result"`
Message string `json:"message"`
Mode string `json:"mode"`
Severity string `json:"severity"`
UpdatedAt time.Time `json:"updatedAt"`
}
type CaptureStatusSubscriber func(CaptureStatus)
var defaultStore = newStore(time.Now)
func SubscribeCaptureStatus(subscriber CaptureStatusSubscriber) func() {
return defaultStore.Subscribe(subscriber)
}
func UpdateCaptureStatus(mode string, result int) {
defaultStore.UpdateCaptureStatus(mode, result)
}
func LatestCaptureStatuses() []CaptureStatus {
return defaultStore.LatestCaptureStatuses()
}
type store struct {
mutex sync.Mutex
latestByMode map[string]CaptureStatus
now func() time.Time
subscribers map[int]CaptureStatusSubscriber
nextSubscriberID int
}
func newStore(now func() time.Time) *store {
return &store{
now: now,
latestByMode: make(map[string]CaptureStatus),
subscribers: make(map[int]CaptureStatusSubscriber),
}
}
func (s *store) Subscribe(subscriber CaptureStatusSubscriber) func() {
s.mutex.Lock()
id := s.nextSubscriberID
s.nextSubscriberID++
s.subscribers[id] = subscriber
s.mutex.Unlock()
return func() {
s.mutex.Lock()
delete(s.subscribers, id)
s.mutex.Unlock()
}
}
func (s *store) UpdateCaptureStatus(mode string, result int) {
next := newCaptureStatus(mode, result, s.now())
subscribers := s.update(next)
for _, subscriber := range subscribers {
subscriber(next)
}
}
func (s *store) update(next CaptureStatus) []CaptureStatusSubscriber {
s.mutex.Lock()
defer s.mutex.Unlock()
last, ok := s.latestByMode[next.Mode]
if ok && samePublicStatus(last, next) {
return nil
}
s.latestByMode[next.Mode] = next
subscribers := make([]CaptureStatusSubscriber, 0, len(s.subscribers))
for _, subscriber := range s.subscribers {
subscribers = append(subscribers, subscriber)
}
return subscribers
}
func (s *store) LatestCaptureStatus(mode string) (CaptureStatus, bool) {
s.mutex.Lock()
defer s.mutex.Unlock()
status, ok := s.latestByMode[mode]
return status, ok
}
func (s *store) LatestCaptureStatuses() []CaptureStatus {
s.mutex.Lock()
defer s.mutex.Unlock()
modes := make([]string, 0, len(s.latestByMode))
for mode := range s.latestByMode {
modes = append(modes, mode)
}
sort.Strings(modes)
statuses := make([]CaptureStatus, 0, len(modes))
for _, mode := range modes {
statuses = append(statuses, s.latestByMode[mode])
}
return statuses
}
func samePublicStatus(a CaptureStatus, b CaptureStatus) bool {
if a.Ok && b.Ok {
return true
}
return a.Ok == b.Ok && a.Result == b.Result && a.Mode == b.Mode
}
func newCaptureStatus(mode string, result int, updatedAt time.Time) CaptureStatus {
message, severity := captureResultMessage(result)
return CaptureStatus{
Ok: result >= 0,
Result: result,
Message: message,
Mode: mode,
Severity: severity,
UpdatedAt: updatedAt,
}
}
func captureResultMessage(result int) (string, string) {
switch result {
case -7:
return "HDMI input resolution error", CaptureSeverityError
case -6:
return "Unsupported HDMI resolution", CaptureSeverityError
case -5:
return "Retrieving image", CaptureSeverityWarning
case -4:
return "Changing image resolution", CaptureSeverityWarning
case -3:
return "Image buffer full", CaptureSeverityError
case -2:
return "Encoder error", CaptureSeverityError
case -1:
return "No image captured", CaptureSeverityError
default:
if result < 0 {
return "Capture failed", CaptureSeverityError
}
return "", ""
}
}

View File

@@ -12,6 +12,7 @@ import (
var (
streamer = newStreamer()
upgrader = websocket.Upgrader{
WriteBufferSize: 256 * 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
@@ -36,7 +37,7 @@ func Connect(c *gin.Context) {
defer streamer.removeClient(ws)
for {
if _, _, err := ws.ReadMessage(); err != nil {
if _, _, err := ws.NextReader(); err != nil {
log.Debugf("failed to read message (client disconnected): %s", err)
return
}

View File

@@ -14,20 +14,25 @@ import (
)
type Streamer struct {
mutex sync.RWMutex
clients map[*websocket.Conn]bool
running int32
mutex sync.Mutex
clients map[*websocket.Conn]bool
clientSnapshot atomic.Pointer[[]*websocket.Conn]
running int32
}
func newStreamer() *Streamer {
return &Streamer{
s := &Streamer{
clients: make(map[*websocket.Conn]bool),
}
s.updateClientSnapshotLocked()
return s
}
func (s *Streamer) addClient(ws *websocket.Conn) {
s.mutex.Lock()
s.clients[ws] = true
s.updateClientSnapshotLocked()
s.mutex.Unlock()
if atomic.CompareAndSwapInt32(&s.running, 0, 1) {
@@ -39,36 +44,53 @@ func (s *Streamer) addClient(ws *websocket.Conn) {
func (s *Streamer) removeClient(ws *websocket.Conn) {
s.mutex.Lock()
delete(s.clients, ws)
count := s.updateClientSnapshotLocked()
s.mutex.Unlock()
log.Debugf("h264 websocket disconnected, remaining clients: %d", len(s.clients))
log.Debugf("h264 websocket disconnected, remaining clients: %d", count)
}
func (s *Streamer) getClientCount() int {
s.mutex.RLock()
defer s.mutex.RUnlock()
func (s *Streamer) updateClientSnapshotLocked() int {
clients := make([]*websocket.Conn, 0, len(s.clients))
for client := range s.clients {
clients = append(clients, client)
}
s.clientSnapshot.Store(&clients)
return len(s.clients)
return len(clients)
}
func (s *Streamer) getClients() []*websocket.Conn {
clients := s.clientSnapshot.Load()
if clients == nil {
return nil
}
return *clients
}
func (s *Streamer) run() {
defer atomic.StoreInt32(&s.running, 0)
duration := time.Second / time.Duration(120)
ticker := time.NewTicker(duration)
screen := common.GetScreen()
common.CheckScreen()
fps := screen.FPS
ticker := time.NewTicker(time.Second / time.Duration(fps))
defer ticker.Stop()
screen := common.GetScreen()
vision := common.GetKvmVision()
startTime := time.Now()
for range ticker.C {
if s.getClientCount() == 0 {
clients := s.getClients()
if len(clients) == 0 {
log.Debug("h264 stream stopped due to no clients")
return
}
data, result := vision.ReadH264(screen.Width, screen.Height, screen.BitRate)
stream.UpdateCaptureStatus(stream.CaptureModeDirect, result)
if result < 0 || len(data) == 0 {
continue
}
@@ -80,28 +102,34 @@ func (s *Streamer) run() {
timestamp := time.Since(startTime).Microseconds()
if err := s.send(isKeyFrame, timestamp, data); err != nil {
if err := s.send(clients, isKeyFrame, timestamp, data); err != nil {
continue
}
if screen.FPS != fps && screen.FPS != 0 {
fps = screen.FPS
ticker.Reset(time.Second / time.Duration(fps))
}
stream.GetFrameRateCounter().Update()
}
}
func (s *Streamer) send(isKeyFrame byte, timestamp int64, data []byte) error {
func (s *Streamer) send(clients []*websocket.Conn, isKeyFrame byte, timestamp int64, data []byte) error {
buf := BufferPool.Get().(*bytes.Buffer)
defer BufferPool.Put(buf)
buf.Reset()
buf.Grow(1 + 8 + len(data))
if err := buf.WriteByte(isKeyFrame); err != nil {
log.Errorf("failed to write keyframe flag: %s", err)
return err
}
tsBytes := make([]byte, 8)
binary.LittleEndian.PutUint64(tsBytes, uint64(timestamp))
if _, err := buf.Write(tsBytes); err != nil {
var tsBytes [8]byte
binary.LittleEndian.PutUint64(tsBytes[:], uint64(timestamp))
if _, err := buf.Write(tsBytes[:]); err != nil {
log.Errorf("failed to write timestamp: %s", err)
return err
}
@@ -111,7 +139,7 @@ func (s *Streamer) send(isKeyFrame byte, timestamp int64, data []byte) error {
return err
}
for client := range s.clients {
for _, client := range clients {
if err := client.WriteMessage(websocket.BinaryMessage, buf.Bytes()); err != nil {
log.Errorf("failed to write message to client %s: %s.", client.RemoteAddr(), err)

View File

@@ -16,9 +16,8 @@ var (
)
type FrameRateCounter struct {
frameCount int32
fps int32
mutex sync.Mutex
frameCount atomic.Int32
fps atomic.Int32
}
func GetFrameRateCounter() *FrameRateCounter {
@@ -30,16 +29,10 @@ func GetFrameRateCounter() *FrameRateCounter {
defer ticker.Stop()
for range ticker.C {
counter.mutex.Lock()
currentCount := counter.frameCount.Swap(0)
counter.fps.Store(currentCount / 3)
currentCount := atomic.LoadInt32(&counter.frameCount)
counter.fps = currentCount / 3
atomic.StoreInt32(&counter.frameCount, 0)
counter.mutex.Unlock()
data := fmt.Sprintf("%d", counter.fps)
data := fmt.Sprintf("%d", counter.fps.Load())
err := os.WriteFile("/kvmapp/kvm/now_fps", []byte(data), 0o666)
if err != nil {
log.Errorf("failed to write fps: %s", err)
@@ -52,12 +45,9 @@ func GetFrameRateCounter() *FrameRateCounter {
}
func (f *FrameRateCounter) Update() {
atomic.AddInt32(&f.frameCount, 1)
f.frameCount.Add(1)
}
func (f *FrameRateCounter) GetFPS() int32 {
f.mutex.Lock()
defer f.mutex.Unlock()
return f.fps
return f.fps.Load()
}

View File

@@ -1,167 +0,0 @@
package h264
import (
"encoding/json"
"sync"
"github.com/gorilla/websocket"
"github.com/pion/webrtc/v4"
log "github.com/sirupsen/logrus"
)
type Client struct {
ws *websocket.Conn
pc *webrtc.PeerConnection
mutex sync.Mutex
}
type Message struct {
Event string `json:"event"`
Data string `json:"data"`
}
// add video track
func (c *Client) addTrack() {
videoTrack, err := webrtc.NewTrackLocalStaticSample(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264},
"video",
"pion",
)
if err != nil {
log.Errorf("failed to create video track: %s", err)
return
}
_, err = c.pc.AddTrack(videoTrack)
if err != nil {
log.Errorf("failed to add video track: %s", err)
return
}
trackMap[c.ws] = videoTrack
}
// register callback events
func (c *Client) register() {
// new ICE candidate found
c.pc.OnICECandidate(func(candidate *webrtc.ICECandidate) {
if candidate == nil {
return
}
candidateByte, err := json.Marshal(candidate.ToJSON())
if err != nil {
log.Errorf("failed to marshal candidate: %s", err)
return
}
_ = c.sendMessage("candidate", string(candidateByte))
})
// ICE connection state has changed
c.pc.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
if state == webrtc.ICEConnectionStateConnected && !isSending {
// start sending h264 data
go send()
isSending = true
}
log.Debugf("ice connection state has changed to %s", state.String())
})
}
// read websocket message
func (c *Client) readMessage() {
message := &Message{}
for {
_, raw, err := c.ws.ReadMessage()
if err != nil {
delete(trackMap, c.ws)
if isSending && len(trackMap) == 0 {
// stop sending when all websocket connections are closed
isSending = false
}
log.Debugf("failed to read message: %s", err)
return
}
if err := json.Unmarshal(raw, &message); err != nil {
log.Errorf("failed to unmarshal message: %s", err)
continue
}
log.Debugf("receive message event: %s", message.Event)
switch message.Event {
case "offer":
offer := webrtc.SessionDescription{}
if err := json.Unmarshal([]byte(message.Data), &offer); err != nil {
log.Errorf("failed to unmarshal offer message: %s", err)
return
}
if err := c.pc.SetRemoteDescription(offer); err != nil {
log.Errorf("failed to set remote description: %s", err)
return
}
answer, answerErr := c.pc.CreateAnswer(nil)
if answerErr != nil {
log.Errorf("failed to create answer: %s", answerErr)
return
}
if err := c.pc.SetLocalDescription(answer); err != nil {
log.Errorf("failed to set local description: %s", err)
return
}
answerByte, answerByteErr := json.Marshal(answer)
if answerByteErr != nil {
log.Errorf("failed to marshal answer: %s", answerByteErr)
return
}
_ = c.sendMessage("answer", string(answerByte))
case "candidate":
candidate := webrtc.ICECandidateInit{}
if err := json.Unmarshal([]byte(message.Data), &candidate); err != nil {
log.Errorf("failed to unmarshal candidate message: %s", err)
return
}
if err := c.pc.AddICECandidate(candidate); err != nil {
log.Errorf("failed to add ICE candidate: %s", err)
return
}
case "heartbeat":
_ = c.sendMessage("heartbeat", "")
default:
log.Debugf("unhandled message event: %s", message.Event)
}
}
}
// send websocket message
func (c *Client) sendMessage(event string, data string) error {
c.mutex.Lock()
defer c.mutex.Unlock()
message := &Message{
Event: event,
Data: data,
}
if err := c.ws.WriteJSON(message); err != nil {
log.Errorf("failed to send message %s: %s", event, err)
return err
}
log.Debugf("send message %s", message.Event)
return nil
}

View File

@@ -1,80 +0,0 @@
package h264
import (
"NanoKVM-Server/config"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/pion/webrtc/v4"
log "github.com/sirupsen/logrus"
)
var (
upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
trackMap = make(map[*websocket.Conn]*webrtc.TrackLocalStaticSample)
isSending = false
)
func Connect(c *gin.Context) {
wsConn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Errorf("failed to create websocket: %s", err)
return
}
defer func() {
_ = wsConn.Close()
log.Debugf("h264 websocket disconnected")
}()
var zeroTime time.Time
_ = wsConn.SetReadDeadline(zeroTime)
conf := config.GetInstance()
var iceServers []webrtc.ICEServer
if conf.Stun != "" && conf.Stun != "disable" {
iceServers = append(iceServers, webrtc.ICEServer{
URLs: []string{"stun:" + conf.Stun},
})
}
if conf.Turn.TurnAddr != "" && conf.Turn.TurnUser != "" && conf.Turn.TurnCred != "" {
iceServers = append(iceServers, webrtc.ICEServer{
URLs: []string{"turn:" + conf.Turn.TurnAddr},
Username: conf.Turn.TurnUser,
Credential: conf.Turn.TurnCred,
})
}
peerConn, err := webrtc.NewPeerConnection(webrtc.Configuration{
ICEServers: iceServers,
})
if err != nil {
log.Errorf("failed to create PeerConnection: %s", err)
return
}
defer func() {
_ = peerConn.Close()
log.Debugf("PeerConnection disconnected")
}()
client := &Client{
ws: wsConn,
pc: peerConn,
mutex: sync.Mutex{},
}
client.addTrack()
client.register()
client.readMessage()
}

View File

@@ -1,49 +0,0 @@
package h264
import (
"NanoKVM-Server/common"
"time"
"github.com/pion/webrtc/v4/pkg/media"
log "github.com/sirupsen/logrus"
)
func send() {
screen := common.GetScreen()
common.CheckScreen()
fps := screen.FPS
duration := time.Second / time.Duration(fps)
ticker := time.NewTicker(duration)
defer ticker.Stop()
vision := common.GetKvmVision()
for range ticker.C {
if !isSending && len(trackMap) == 0 {
return
}
data, result := vision.ReadH264(screen.Width, screen.Height, screen.BitRate)
if result < 0 || len(data) == 0 {
continue
}
sample := media.Sample{
Data: data,
Duration: duration,
}
for _, track := range trackMap {
if err := track.WriteSample(sample); err != nil {
log.Errorf("failed to send h264 data: %s", err)
}
}
if screen.FPS != fps {
fps = screen.FPS
duration = time.Second / time.Duration(fps)
ticker.Reset(duration)
}
}
}

View File

@@ -8,6 +8,13 @@ import (
var streamer = NewStreamer()
type LatestFrame struct {
Data []byte
Width uint16
Height uint16
CapturedAt time.Time
}
func Connect(c *gin.Context) {
c.Header("Content-Type", "multipart/x-mixed-replace; boundary=frame")
c.Header("Cache-Control", "no-cache")
@@ -20,3 +27,15 @@ func Connect(c *gin.Context) {
<-c.Request.Context().Done()
}
func GetLatestFrame() (LatestFrame, bool) {
return streamer.getLatestFrame()
}
func EnableLatestFrameCache() {
streamer.enableLatestFrameCache()
}
func DisableLatestFrameCache() {
streamer.disableLatestFrameCache()
}

View File

@@ -13,21 +13,31 @@ import (
log "github.com/sirupsen/logrus"
)
var crlf = []byte("\r\n")
type Streamer struct {
mutex sync.RWMutex
clients map[*gin.Context]bool
running int32
mutex sync.Mutex
clients map[*gin.Context]bool
clientSnapshot atomic.Pointer[[]*gin.Context]
running int32
frameMutex sync.RWMutex
latestFrame LatestFrame
cacheRefs int32
}
func NewStreamer() *Streamer {
return &Streamer{
s := &Streamer{
clients: make(map[*gin.Context]bool),
}
s.updateClientSnapshotLocked()
return s
}
func (s *Streamer) AddClient(c *gin.Context) {
s.mutex.Lock()
s.clients[c] = true
s.updateClientSnapshotLocked()
s.mutex.Unlock()
if atomic.CompareAndSwapInt32(&s.running, 0, 1) {
@@ -39,28 +49,29 @@ func (s *Streamer) AddClient(c *gin.Context) {
func (s *Streamer) RemoveClient(c *gin.Context) {
s.mutex.Lock()
delete(s.clients, c)
count := s.updateClientSnapshotLocked()
s.mutex.Unlock()
log.Debugf("mjpeg connection removed, remaining clients: %d", len(s.clients))
log.Debugf("mjpeg connection removed, remaining clients: %d", count)
}
func (s *Streamer) getClients() []*gin.Context {
s.mutex.RLock()
defer s.mutex.RUnlock()
func (s *Streamer) updateClientSnapshotLocked() int {
clients := make([]*gin.Context, 0, len(s.clients))
for c := range s.clients {
clients = append(clients, c)
}
s.clientSnapshot.Store(&clients)
return clients
return len(clients)
}
func (s *Streamer) getClientCount() int {
s.mutex.RLock()
defer s.mutex.RUnlock()
func (s *Streamer) getClients() []*gin.Context {
clients := s.clientSnapshot.Load()
if clients == nil {
return nil
}
return len(s.clients)
return *clients
}
func (s *Streamer) run() {
@@ -76,17 +87,22 @@ func (s *Streamer) run() {
defer ticker.Stop()
for range ticker.C {
if s.getClientCount() == 0 {
clients := s.getClients()
if len(clients) == 0 {
log.Debug("mjpeg stream stopped due to no clients")
return
}
data, result := vision.ReadMjpeg(screen.Width, screen.Height, screen.Quality)
stream.UpdateCaptureStatus(stream.CaptureModeMJPEG, result)
if result < 0 || result == 5 || len(data) == 0 {
continue
}
clients := s.getClients()
if s.frameCacheEnabled() {
s.setLatestFrame(data, screen.Width, screen.Height)
}
for _, client := range clients {
if err := writeFrame(client, data); err != nil {
log.Errorf("failed to write mjpeg frame for client %s: %s", client.Request.RemoteAddr, err)
@@ -103,6 +119,71 @@ func (s *Streamer) run() {
}
}
func (s *Streamer) setLatestFrame(data []byte, width uint16, height uint16) {
frameCopy := append([]byte(nil), data...)
s.frameMutex.Lock()
defer s.frameMutex.Unlock()
s.latestFrame = LatestFrame{
Data: frameCopy,
Width: width,
Height: height,
CapturedAt: time.Now(),
}
}
func (s *Streamer) clearLatestFrame() {
s.frameMutex.Lock()
defer s.frameMutex.Unlock()
s.latestFrame = LatestFrame{}
}
func (s *Streamer) enableLatestFrameCache() {
atomic.AddInt32(&s.cacheRefs, 1)
}
func (s *Streamer) disableLatestFrameCache() {
for {
current := atomic.LoadInt32(&s.cacheRefs)
if current <= 0 {
return
}
if atomic.CompareAndSwapInt32(&s.cacheRefs, current, current-1) {
if current == 1 {
s.clearLatestFrame()
}
return
}
}
}
func (s *Streamer) frameCacheEnabled() bool {
return atomic.LoadInt32(&s.cacheRefs) > 0
}
func (s *Streamer) getLatestFrame() (LatestFrame, bool) {
if !s.frameCacheEnabled() {
return LatestFrame{}, false
}
s.frameMutex.RLock()
defer s.frameMutex.RUnlock()
if len(s.latestFrame.Data) == 0 {
return LatestFrame{}, false
}
return LatestFrame{
Data: append([]byte(nil), s.latestFrame.Data...),
Width: s.latestFrame.Width,
Height: s.latestFrame.Height,
CapturedAt: s.latestFrame.CapturedAt,
}, true
}
func writeFrame(c *gin.Context, data []byte) (err error) {
defer func() {
if r := recover(); r != nil {
@@ -122,7 +203,7 @@ func writeFrame(c *gin.Context, data []byte) (err error) {
return err
}
if _, err = c.Writer.Write([]byte("\r\n")); err != nil {
if _, err = c.Writer.Write(crlf); err != nil {
return err
}

View File

@@ -21,6 +21,20 @@ func NewClient(ws *websocket.Conn, videoConn *webrtc.PeerConnection) *Client {
}
}
func (c *Client) Close() {
if c.video != nil {
if err := c.video.Close(); err != nil {
log.Debugf("failed to close video peer connection: %s", err)
}
}
if c.ws != nil {
if err := c.ws.Close(); err != nil {
log.Debugf("failed to close websocket: %s", err)
}
}
}
func (c *Client) WriteMessage(event string, data string) error {
c.mutex.Lock()
defer c.mutex.Unlock()

View File

@@ -2,6 +2,7 @@ package webrtc
import (
"NanoKVM-Server/config"
"encoding/json"
"net/http"
"sync"
"time"
@@ -15,6 +16,7 @@ import (
var (
upgrader = websocket.Upgrader{
WriteBufferSize: 256 * 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
@@ -79,6 +81,10 @@ func Connect(c *gin.Context) {
// handle signaling
signalingHandler := NewSignalingHandler(client)
signalingHandler.RegisterCallbacks()
if err := sendICEServers(client, iceServers); err != nil {
log.Errorf("failed to send ICE servers: %s", err)
return
}
// read and wait
for {
@@ -116,6 +122,30 @@ func createICEServers() []webrtc.ICEServer {
return iceServers
}
type clientICEServer struct {
URLs []string `json:"urls"`
Username string `json:"username,omitempty"`
Credential interface{} `json:"credential,omitempty"`
}
func sendICEServers(client *Client, iceServers []webrtc.ICEServer) error {
clientServers := make([]clientICEServer, 0, len(iceServers))
for _, server := range iceServers {
clientServers = append(clientServers, clientICEServer{
URLs: server.URLs,
Username: server.Username,
Credential: server.Credential,
})
}
data, err := json.Marshal(clientServers)
if err != nil {
return err
}
return client.WriteMessage("ice-servers", string(data))
}
func createMediaEngine() (*webrtc.MediaEngine, error) {
mediaEngine := &webrtc.MediaEngine{}

View File

@@ -3,7 +3,6 @@ package webrtc
import (
"NanoKVM-Server/common"
"NanoKVM-Server/service/stream"
"sync"
"sync/atomic"
"time"
@@ -13,11 +12,13 @@ import (
)
func NewWebRTCManager() *WebRTCManager {
return &WebRTCManager{
m := &WebRTCManager{
clients: make(map[*websocket.Conn]*Client),
videoSending: 0,
mutex: sync.RWMutex{},
}
m.updateClientSnapshotLocked()
return m
}
func (m *WebRTCManager) AddClient(ws *websocket.Conn, client *Client) {
@@ -25,24 +26,42 @@ func (m *WebRTCManager) AddClient(ws *websocket.Conn, client *Client) {
m.mutex.Lock()
m.clients[ws] = client
count := m.updateClientSnapshotLocked()
m.mutex.Unlock()
log.Debugf("added client %s, total clients: %d", ws.RemoteAddr(), len(m.clients))
log.Debugf("added client %s, total clients: %d", ws.RemoteAddr(), count)
}
func (m *WebRTCManager) RemoveClient(ws *websocket.Conn) {
m.mutex.Lock()
delete(m.clients, ws)
count := m.updateClientSnapshotLocked()
m.mutex.Unlock()
log.Debugf("removed client %s, total clients: %d", ws.RemoteAddr(), len(m.clients))
log.Debugf("removed client %s, total clients: %d", ws.RemoteAddr(), count)
}
func (m *WebRTCManager) GetClientCount() int {
m.mutex.RLock()
defer m.mutex.RUnlock()
return len(m.getClients())
}
return len(m.clients)
func (m *WebRTCManager) updateClientSnapshotLocked() int {
clients := make([]*Client, 0, len(m.clients))
for _, client := range m.clients {
clients = append(clients, client)
}
m.clientSnapshot.Store(&clients)
return len(clients)
}
func (m *WebRTCManager) getClients() []*Client {
clients := m.clientSnapshot.Load()
if clients == nil {
return nil
}
return *clients
}
func (m *WebRTCManager) StartVideoStream() {
@@ -66,12 +85,14 @@ func (m *WebRTCManager) sendVideoStream() {
defer ticker.Stop()
for range ticker.C {
if m.GetClientCount() == 0 {
clients := m.getClients()
if len(clients) == 0 {
log.Debugf("stop sending h264 stream")
return
}
data, result := vision.ReadH264(screen.Width, screen.Height, screen.BitRate)
stream.UpdateCaptureStatus(stream.CaptureModeH264, result)
if result < 0 || len(data) == 0 {
continue
}
@@ -81,8 +102,12 @@ func (m *WebRTCManager) sendVideoStream() {
Duration: duration,
}
for _, client := range m.clients {
client.track.writeVideo(sample)
for _, client := range clients {
if err := client.track.writeVideoSample(sample); err != nil {
log.Errorf("failed to write h264 video to client: %s", err)
m.RemoveClient(client.WsConn())
client.Close()
}
}
if screen.FPS != fps && screen.FPS != 0 {

View File

@@ -11,7 +11,7 @@ func (t *Track) updateExtension() {
t.playoutDelayExtensionID = 5
}
if t.playoutDelayExtensionData == nil || len(t.playoutDelayExtensionData) == 0 {
if len(t.playoutDelayExtensionData) == 0 {
playoutDelay := &rtp.PlayoutDelayExtension{
MinDelay: 0,
MaxDelay: 0,
@@ -44,10 +44,3 @@ func (t *Track) writeVideoSample(sample media.Sample) error {
return nil
}
func (t *Track) writeVideo(sample media.Sample) {
err := t.writeVideoSample(sample)
if err != nil {
log.Errorf("failed to write h264 video: %s", err)
}
}

View File

@@ -2,6 +2,7 @@ package webrtc
import (
"sync"
"sync/atomic"
"github.com/gorilla/websocket"
"github.com/pion/rtp"
@@ -9,9 +10,10 @@ import (
)
type WebRTCManager struct {
clients map[*websocket.Conn]*Client
videoSending int32
mutex sync.RWMutex
clients map[*websocket.Conn]*Client
clientSnapshot atomic.Pointer[[]*Client]
videoSending int32
mutex sync.Mutex
}
type Client struct {
@@ -21,6 +23,10 @@ type Client struct {
mutex sync.Mutex
}
func (c *Client) WsConn() *websocket.Conn {
return c.ws
}
type SignalingHandler struct {
client *Client
}

View File

@@ -0,0 +1,111 @@
package ws
import (
"encoding/json"
"sort"
"sync"
"NanoKVM-Server/service/stream"
log "github.com/sirupsen/logrus"
)
var captureBroadcaster = newCaptureStatusBroadcaster(broadcastCaptureStatus)
func init() {
go captureBroadcaster.Run()
stream.SubscribeCaptureStatus(func(status stream.CaptureStatus) {
captureBroadcaster.Enqueue(status)
})
}
type captureStatusBroadcaster struct {
mutex sync.Mutex
pending map[string]stream.CaptureStatus
notify chan struct{}
broadcast func(stream.CaptureStatus)
}
func newCaptureStatusBroadcaster(broadcast func(stream.CaptureStatus)) *captureStatusBroadcaster {
return &captureStatusBroadcaster{
pending: make(map[string]stream.CaptureStatus),
notify: make(chan struct{}, 1),
broadcast: broadcast,
}
}
func (b *captureStatusBroadcaster) Enqueue(status stream.CaptureStatus) {
b.mutex.Lock()
b.pending[status.Mode] = status
b.mutex.Unlock()
select {
case b.notify <- struct{}{}:
default:
}
}
func (b *captureStatusBroadcaster) Run() {
for range b.notify {
for {
statuses := b.takePending()
if len(statuses) == 0 {
break
}
for _, status := range statuses {
b.broadcast(status)
}
}
}
}
func (b *captureStatusBroadcaster) takePending() []stream.CaptureStatus {
b.mutex.Lock()
defer b.mutex.Unlock()
if len(b.pending) == 0 {
return nil
}
modes := make([]string, 0, len(b.pending))
for mode := range b.pending {
modes = append(modes, mode)
}
sort.Strings(modes)
statuses := make([]stream.CaptureStatus, 0, len(modes))
for _, mode := range modes {
statuses = append(statuses, b.pending[mode])
delete(b.pending, mode)
}
return statuses
}
func sendCaptureStatusSnapshot(client *Client) {
for _, status := range stream.LatestCaptureStatuses() {
if err := sendCaptureStatus(client, status); err != nil {
log.Errorf("failed to send capture status snapshot: %s", err)
}
}
}
func broadcastCaptureStatus(status stream.CaptureStatus) {
for _, client := range GetManager().GetClients() {
if err := sendCaptureStatus(client, status); err != nil {
log.Errorf("failed to send capture status: %s", err)
}
}
}
func sendCaptureStatus(client *Client, status stream.CaptureStatus) error {
payload, err := json.Marshal(status)
if err != nil {
log.Errorf("failed to marshal capture status: %s", err)
return err
}
return client.Write(stream.CaptureStatusEvent, string(payload))
}

View File

@@ -5,6 +5,7 @@ import (
"time"
"NanoKVM-Server/service/hid"
"NanoKVM-Server/service/picoclaw"
"NanoKVM-Server/service/vm/jiggler"
"github.com/gorilla/websocket"
@@ -50,14 +51,26 @@ func (c *Client) Read() error {
return err
}
if len(data) == 0 {
continue
}
log.Debugf("received message %d: %v", messageType, data)
switch data[0] {
case Heartbeat:
c.UpdateHeartbeat()
case KeyboardEvent:
if picoclaw.GetSessionLock().BlocksManualInput() {
log.Debug("manual keyboard input dropped while AI session holds control")
continue
}
writeQueue(c.keyboard, data[1:])
case MouseEvent:
if picoclaw.GetSessionLock().BlocksManualInput() {
log.Debug("manual mouse input dropped while AI session holds control")
continue
}
writeQueue(c.mouse, data[1:])
}
}
@@ -89,21 +102,42 @@ func (c *Client) UpdateHeartbeat() {
}
func (c *Client) Close() {
_ = c.ws.Close()
c.closeOnce.Do(func() {
if c.ws != nil {
_ = c.ws.Close()
}
closeQueue(c.keyboard)
closeQueue(c.mouse)
if c.keyboard != nil {
close(c.keyboard)
}
if c.mouse != nil {
close(c.mouse)
}
log.Debug("websocket disconnected")
log.Debug("websocket disconnected")
})
}
func writeQueue(queue chan []byte, data []byte) {
queue <- data
if !sendQueue(queue, data) {
log.Debug("hid event dropped because websocket queue is closed")
return
}
jiggler.GetJiggler().Update()
}
func closeQueue(queue chan []byte) {
for range queue {
func sendQueue(queue chan []byte, data []byte) (ok bool) {
if queue == nil {
return false
}
close(queue)
defer func() {
if r := recover(); r != nil {
ok = false
}
}()
queue <- data
return true
}

View File

@@ -37,5 +37,7 @@ func (s *Service) Connect(c *gin.Context) {
manager.AddClient(ws, client)
defer manager.RemoveClient(ws)
sendCaptureStatusSnapshot(client)
client.Start()
}

View File

@@ -21,6 +21,7 @@ type Client struct {
mouse chan []byte
lastHeartbeat time.Time
mutex sync.Mutex
closeOnce sync.Once
}
type Message struct {

28
server/utils/listener.go Normal file
View File

@@ -0,0 +1,28 @@
package utils
import (
"fmt"
"net"
"strings"
)
func ListenAddr(host string, port string) string {
if host == "" {
return fmt.Sprintf(":%s", port)
}
return net.JoinHostPort(host, port)
}
func NeedsDedicatedLoopbackListener(host string) bool {
host = strings.TrimSpace(strings.Trim(host, "[]"))
if host == "" {
return false
}
ip := net.ParseIP(host)
if ip == nil {
return !strings.EqualFold(host, "localhost")
}
return !ip.IsLoopback() && !ip.IsUnspecified()
}

View File

@@ -164,6 +164,7 @@ uint16_t hdmi_res_list[][2] = {
{1152, 864},
{1024, 768},
{800, 600},
{640, 480},
};
uint16_t hdmi_unsupported_res_list[][2] = {
@@ -221,7 +222,7 @@ uint8_t get_vi_state()
uint8_t VIWHGTLSCnt[4];
FILE* fp = popen( cmd, "r" );
uint8_t ret = 0;
if (fgets(VI_State, sizeof(VI_State), fp) != NULL){
FPS[0] = atoi(VI_State);
// debug("VIDevFPS = %d\n", FPS[0]);
@@ -317,7 +318,7 @@ uint8_t watchdog_sf_is_open(void)
else return 0;
}
int vision_update_watchdog()
int vision_update_watchdog()
{
FILE *file;
@@ -343,7 +344,7 @@ int get_manual_resolution(void)
if(access("/kvmapp/kvm/width", F_OK) == 0){
fp = fopen("/kvmapp/kvm/width", "r");
fseek(fp, 0, SEEK_END);
file_size = ftell(fp);
file_size = ftell(fp);
fseek(fp, 0, SEEK_SET);
fread(RW_Data, sizeof(char), file_size, fp);
fclose(fp);
@@ -355,7 +356,7 @@ int get_manual_resolution(void)
if(access("/kvmapp/kvm/height", F_OK) == 0){
fp = fopen("/kvmapp/kvm/height", "r");
fseek(fp, 0, SEEK_END);
file_size = ftell(fp);
file_size = ftell(fp);
fseek(fp, 0, SEEK_SET);
fread(RW_Data, sizeof(char), file_size, fp);
fclose(fp);
@@ -494,15 +495,20 @@ uint8_t chack_ion()
void lt6911_enable()
{
uint8_t buf[2];
if(kvmv_cfg.hdmi_version == 3){
return;
}
buf[0] = 0xff;
buf[1] = 0x80;
LT6911_i2c.writeto(LT6911_ADDR, buf, 2);
buf[0] = 0xee;
buf[1] = 0x01;
LT6911_i2c.writeto(LT6911_ADDR, buf, 2);
if(kvmv_cfg.hdmi_version != 0){
if(kvmv_cfg.hdmi_version == 1){
// disable watchdog
buf[0] = 0x10;
buf[1] = 0x00;
@@ -513,10 +519,15 @@ void lt6911_enable()
void lt6911_disable()
{
uint8_t buf[2];
if(kvmv_cfg.hdmi_version == 3){
return;
}
buf[0] = 0xff;
buf[1] = 0x80;
LT6911_i2c.writeto(LT6911_ADDR, buf, 2);
buf[0] = 0xee;
buf[1] = 0x00;
LT6911_i2c.writeto(LT6911_ADDR, buf, 2);
@@ -628,6 +639,38 @@ uint8_t lt6911_get_hdmi_res()
if(revbuf[0] == 0x55) return 1;
else if(revbuf[0] == 0x88) return 0;
else return 0;
} else if (kvmv_cfg.hdmi_version == 3){
// LT6911D
buf[0] = 0xff;
buf[1] = 0xe0;
LT6911_i2c.writeto(LT6911_ADDR, buf, 2);
// Hactive
buf[0] = 0x8c;
LT6911_i2c.writeto(LT6911_ADDR, buf, 1);
maix::Bytes *dat0 = LT6911_i2c.readfrom(LT6911_ADDR, 2);
// Vactive
buf[0] = 0x8e;
LT6911_i2c.writeto(LT6911_ADDR, buf, 1);
maix::Bytes *dat1 = LT6911_i2c.readfrom(LT6911_ADDR, 2);
revbuf[0] = (uint8_t)dat0->data[0];
revbuf[1] = (uint8_t)dat0->data[1];
revbuf[2] = (uint8_t)dat1->data[0];
revbuf[3] = (uint8_t)dat1->data[1];
Hactive = ((revbuf[0] << 8)|revbuf[1]) * 2;
Vactive = (revbuf[2] << 8)|revbuf[3];
debug("[hdmi]HDMI-D res modification event\n");
debug("[hdmi]new res: %d * %d\n", Hactive, Vactive);
delete dat0;
delete dat1;
if(Hactive != 0 || Vactive != 0) return 1;
else return 0;
} else {
return 0;
}
@@ -685,7 +728,7 @@ uint8_t lt6911_get_csi_res(uint16_t *p_width, uint16_t *p_height)
uint16_t Hactive;
if(kvmv_cfg.hdmi_version == 0){
// LT6911C
// LT6911C
buf[0] = 0xff;
buf[1] = 0xc2;
LT6911_i2c.writeto(LT6911_ADDR, buf, 2);
@@ -704,7 +747,7 @@ uint8_t lt6911_get_csi_res(uint16_t *p_width, uint16_t *p_height)
revbuf[1] = (uint8_t)dat0->data[1];
revbuf[2] = (uint8_t)dat1->data[0];
revbuf[3] = (uint8_t)dat1->data[1];
delete dat0;
delete dat1;
@@ -737,6 +780,33 @@ uint8_t lt6911_get_csi_res(uint16_t *p_width, uint16_t *p_height)
Vactive = (revbuf[0] << 8)|revbuf[1];
Hactive = (revbuf[2] << 8)|revbuf[3];
} else if(kvmv_cfg.hdmi_version == 3) {
// LT6911D
debug("[hdmi]D get csi res\n");
buf[0] = 0xff;
buf[1] = 0xe0;
LT6911_i2c.writeto(LT6911_ADDR, buf, 2);
// Vactive
buf[0] = 0x8e;
LT6911_i2c.writeto(LT6911_ADDR, buf, 1);
maix::Bytes *dat0 = LT6911_i2c.readfrom(LT6911_ADDR, 2);
// Hactive
buf[0] = 0x8c;
LT6911_i2c.writeto(LT6911_ADDR, buf, 1);
maix::Bytes *dat1 = LT6911_i2c.readfrom(LT6911_ADDR, 2);
revbuf[0] = (uint8_t)dat0->data[0];
revbuf[1] = (uint8_t)dat0->data[1];
revbuf[2] = (uint8_t)dat1->data[0];
revbuf[3] = (uint8_t)dat1->data[1];
delete dat0;
delete dat1;
Vactive = (revbuf[0] << 8)|revbuf[1];
Hactive = ((revbuf[2] << 8)|revbuf[3]) * 2;
} else {
return UNKNOWN_RES;
}
@@ -789,7 +859,7 @@ void lt6911_read_reg(uint8_t reg)
buf[0] = reg;
LT6911_i2c.writeto(LT6911_ADDR, buf, 1);
maix::Bytes *dat = LT6911_i2c.readfrom(LT6911_ADDR, 16);
maix::Bytes *dat = LT6911_i2c.readfrom(LT6911_ADDR, 16);
for(int i = 0; i < 16; i++){
buf[i] = (uint8_t)dat->data[i];
@@ -808,7 +878,7 @@ uint8_t lt6911_read_one_reg(uint8_t reg)
ret = reg;
LT6911_i2c.writeto(LT6911_ADDR, &ret, 1);
maix::Bytes *dat = LT6911_i2c.readfrom(LT6911_ADDR, 1);
maix::Bytes *dat = LT6911_i2c.readfrom(LT6911_ADDR, 1);
ret = (uint8_t)dat->data[0];
@@ -845,7 +915,7 @@ void lt6911_write_edid(void)
lt6911_write_reg(0x5E, 0xEF);
lt6911_write_reg(0x5A, 0xA2);
lt6911_write_reg(0x5A, 0x82);
lt6911_write_reg(0x58, 0x01);
if(i < 8) {
@@ -1047,6 +1117,10 @@ void get_hdmi_version()
kvmv_cfg.hdmi_version = 1;
debug("[hdmi]Incomplete version number, set to 'ux'\n");
}
} else if(RW_Data[0] == 'd'){
// 6911d
kvmv_cfg.hdmi_version = 3;
debug("[hdmi]HDMI-D exist!\n");
} else {
// 6911c
kvmv_cfg.hdmi_version = 0;
@@ -1084,7 +1158,7 @@ void* vi_subsystem_detection(void * arg)
{
if(kvmv_cfg.try_exit_thread == 1)
break;
uint8_t get_new_hdmi_mode = get_hdmi_mode();
uint8_t try_res;
uint8_t err_code;
@@ -1108,7 +1182,7 @@ void* vi_subsystem_detection(void * arg)
fp = fopen(hdmi_state_path, "r+");
if(fp != NULL){
// fseek(fp, 0, SEEK_END);
// file_size = ftell(fp);
// file_size = ftell(fp);
// fseek(fp, 0, SEEK_SET);
fread(RW_Data, sizeof(char), 2, fp);
tmp8 = atoi((char*)RW_Data);
@@ -1133,7 +1207,7 @@ void* vi_subsystem_detection(void * arg)
kvmv_cfg.hdmi_res_type = lt6911_get_csi_res(&kvmv_cfg.vi_width, &kvmv_cfg.vi_height);
if (kvmv_cfg.hdmi_res_type == NEW_RES) kvmv_cfg.reopen_cam_flag = 1;
else if (kvmv_cfg.hdmi_res_type == UNKNOWN_RES){
/* Move HDMI resolution modification directly
/* Move HDMI resolution modification directly
to mode1 to solve deadlock problem */
auto_change_mode = 1;
set_hdmi_mode(1);
@@ -1158,7 +1232,7 @@ void* vi_subsystem_detection(void * arg)
kvmv_cfg.hdmi_res_type = lt6911_get_csi_res(&kvmv_cfg.vi_width, &kvmv_cfg.vi_height);
if (kvmv_cfg.hdmi_res_type == NEW_RES) kvmv_cfg.reopen_cam_flag = 1;
else if (kvmv_cfg.hdmi_res_type == UNKNOWN_RES){
/* Move HDMI resolution modification directly
/* Move HDMI resolution modification directly
to mode1 to solve deadlock problem */
auto_change_mode = 1;
set_hdmi_mode(1);
@@ -1170,6 +1244,31 @@ void* vi_subsystem_detection(void * arg)
}
lt6911_disable();
}
} else if (kvmv_cfg.hdmi_version == 3){
// LT6911D
debug("[hdmi] D int\n");
if(falling_times != 0){
debug("[hdmi] D int && \n");
lt6911_enable();
if(lt6911_get_hdmi_res()){
// hdmi get res
debug("[hdmi] D HDMI cable insertion!\n");
kvmv_cfg.hdmi_cable_state = 1;
kvmv_cfg.hdmi_res_type = lt6911_get_csi_res(&kvmv_cfg.vi_width, &kvmv_cfg.vi_height);
if (kvmv_cfg.hdmi_res_type == NEW_RES) kvmv_cfg.reopen_cam_flag = 1;
else if (kvmv_cfg.hdmi_res_type == UNKNOWN_RES){
/* Move HDMI resolution modification directly
to mode1 to solve deadlock problem */
auto_change_mode = 1;
set_hdmi_mode(1);
}
} else {
// HDMI res = 0*0/x*0
debug("[hdmi] D HDMI cable unplugged!\n");
kvmv_cfg.hdmi_cable_state = 0;
}
lt6911_disable();
}
} else {
debug("[hdmi] Chip not supported for reading \n");
}
@@ -1179,15 +1278,15 @@ void* vi_subsystem_detection(void * arg)
fclose(fp);
}
break;
/* Mode 1 & 2 will disable API access to the image during detection,
/* Mode 1 & 2 will disable API access to the image during detection,
and will output -4: Modifying image resolution, please wait.*/
case 1: // Automatically trying common resolutions
/* kvmv_cfg.vi_detect_state :
/* kvmv_cfg.vi_detect_state :
* 0: HDMI standard mode, detection program does not interfere with the camera
* 1: Preparing / Testing in progress
* 2: Test completed: Suitable resolution found,
*/
if(get_new_hdmi_mode == 1){
kvmv_cfg.vi_detect_state = 1;
}
@@ -1198,9 +1297,9 @@ void* vi_subsystem_detection(void * arg)
kvmv_cfg.hdmi_try_rounds = 0;
kvmv_cfg.vi_detect_state = 2;
} else if (try_res == 0) {
/* There may be a situation where the correct resolution cannot be recognized
after one round of detection. By default, Try_rounds_HDMI_err_res rounds will be detected,
and if it cannot be detected, jump to the next mode */
/* There may be a situation where the correct resolution cannot be recognized
after one round of detection. By default, Try_rounds_HDMI_err_res rounds will be detected,
and if it cannot be detected, jump to the next mode */
kvmv_cfg.hdmi_res_err = ERROR_RES;
kvmv_cfg.hdmi_try_rounds++;
if(kvmv_cfg.hdmi_try_rounds >= Try_rounds_HDMI_err_res){
@@ -1225,7 +1324,7 @@ void* vi_subsystem_detection(void * arg)
if (err_code != 1) {
kvmv_cfg.vi_detect_state = 1;
}
time::sleep_ms(1000);
time::sleep_ms(1000);
} else {
kvmv_cfg.vi_detect_state = 1;
}
@@ -1279,7 +1378,7 @@ void* vi_subsystem_detection(void * arg)
}
}
break;
default:
debug("Non-existent hdmi state = %d\n", kvmv_cfg.hdmi_mode);
break;
@@ -1307,7 +1406,7 @@ int sync_vi_res()
} else {
fp = fopen(vi_width_path, "r");
fseek(fp, 0, SEEK_END);
file_size = ftell(fp);
file_size = ftell(fp);
fseek(fp, 0, SEEK_SET);
fread(RW_Data, sizeof(char), file_size, fp);
fclose(fp);
@@ -1327,7 +1426,7 @@ int sync_vi_res()
} else {
fp = fopen(vi_height_path, "r");
fseek(fp, 0, SEEK_END);
file_size = ftell(fp);
file_size = ftell(fp);
fseek(fp, 0, SEEK_SET);
fread(RW_Data, sizeof(char), file_size, fp);
fclose(fp);
@@ -1464,15 +1563,15 @@ int8_t raw_to_h264(image::Image *raw, kvmv_data_t* ret_stream, uint16_t _qlty)
// log::info("getimg: %d \r\n", (int)(time::time_ms()));
mmf_stream_t _stream = {0};
if(kvm_venc.enc_h264_init != 1 || raw->width() != kvm_venc.kvm_venc_cfg.w || raw->height() != kvm_venc.kvm_venc_cfg.h || _qlty != kvm_venc.kvm_venc_cfg.bitrate){
debug("[kvmv]init_venc_h264 enc_h264_init = %d; raw->width() = %d | %d raw->height() = %d | %d \n",
kvm_venc.enc_h264_init,
raw->width(), kvm_venc.kvm_venc_cfg.w,
debug("[kvmv]init_venc_h264 enc_h264_init = %d; raw->width() = %d | %d raw->height() = %d | %d \n",
kvm_venc.enc_h264_init,
raw->width(), kvm_venc.kvm_venc_cfg.w,
raw->height(), kvm_venc.kvm_venc_cfg.h);
init_venc_h264(raw->width(), raw->height(), _qlty);
debug("[kvmv]init_venc_h264 finish enc_h264_init = %d; raw->width() = %d | %d raw->height() = %d | %d \n",
kvm_venc.enc_h264_init,
raw->width(), kvm_venc.kvm_venc_cfg.w,
init_venc_h264(raw->width(), raw->height(), _qlty);
debug("[kvmv]init_venc_h264 finish enc_h264_init = %d; raw->width() = %d | %d raw->height() = %d | %d \n",
kvm_venc.enc_h264_init,
raw->width(), kvm_venc.kvm_venc_cfg.w,
raw->height(), kvm_venc.kvm_venc_cfg.h);
// if(kvm_venc.enc_h264_init == 1){
// init_venc_h264(raw->width(), raw->height(), _qlty);
@@ -1488,8 +1587,8 @@ int8_t raw_to_h264(image::Image *raw, kvmv_data_t* ret_stream, uint16_t _qlty)
debug("[kvmv]mmf venc push failed!\n");
// err::check_raise(err::ERR_RUNTIME, "mmf venc push failed!\r\n");
return -1;
}
// log::info("push(): %d \r\n", (int)(time::time_ms() - start_time));
}
// log::info("push(): %d \r\n", (int)(time::time_ms() - start_time));
if (mmf_venc_pop(kvm_venc.mmf_venc_chn, &_stream)) {
// log::error("mmf_venc_pop failed\n");
mmf_venc_free(kvm_venc.mmf_venc_chn);
@@ -1499,10 +1598,10 @@ int8_t raw_to_h264(image::Image *raw, kvmv_data_t* ret_stream, uint16_t _qlty)
// rtmp->unlock();
return -1;
}
// log::info("pop(): %d \r\n", (int)(time::time_ms() - start_time));
// log::info("pop(): %d \r\n", (int)(time::time_ms() - start_time));
ret = h264_stream_dump(ret_stream, &_stream);
mmf_venc_free(kvm_venc.mmf_venc_chn);
// log::info("dump(): %d \r\n", (int)(time::time_ms() - start_time));
// log::info("dump(): %d \r\n", (int)(time::time_ms() - start_time));
// debug("[kvmv]_stream.data[0][4] = %d;\n", _stream.data[0][4]);
debug("[kvmv]Frame size = %d;\n", ret_stream->img_data_size);
@@ -1538,7 +1637,7 @@ void kvmv_init(uint8_t _debug_info_en)
kvmv_cfg.try_exit_thread = 0;
// debug("[kvmv]kvmv_init - 2\r\n");
if(kvmv_cfg.thread_is_running == 1){
debug("[kvmv]thread is running!\r\n");
} else {
@@ -1655,14 +1754,14 @@ int kvmv_read_img(uint16_t _width, uint16_t _height, uint8_t _type, uint16_t _ql
// Set the output
kvmv_cfg.vpss_width = _width;
kvmv_cfg.vpss_height = _height;
cam->set_resolution(kvmv_cfg.vpss_width, kvmv_cfg.vpss_height);
kvmv_cfg.reinit_flag = 1;
}
}
//
if (kvmv_cfg.reinit_flag == 1) {
if (kvmv_cfg.reinit_flag == 1) {
cam->hmirror(1);
cam->vflip(1);
kvmv_cfg.reinit_flag = 0;
@@ -1670,7 +1769,7 @@ int kvmv_read_img(uint16_t _width, uint16_t _height, uint8_t _type, uint16_t _ql
// debug("[kvmv]befor read img: %d \r\n", (int)(time::time_ms() - start_time));
image::Image *img = cam->read();
// debug("[kvmv]read img: %d \r\n", (int)(time::time_ms() - start_time));
if(img != NULL){
// frame detect
if(_type == VENC_MJPEG && kvmv_cfg.frame_detact != 0){
@@ -1679,10 +1778,10 @@ int kvmv_read_img(uint16_t _width, uint16_t _height, uint8_t _type, uint16_t _ql
} else {
frame_undetact_count = kvmv_cfg.frame_detact;
}
if(frame_undetact_count == kvmv_cfg.frame_detact){
frame_undetact_count = 0;
if(frame_changed(img) == 0){
debug("[kvmv]frame not changed...\n");
kvmv_cfg.stream_stop = 1;
@@ -1693,7 +1792,7 @@ int kvmv_read_img(uint16_t _width, uint16_t _height, uint8_t _type, uint16_t _ql
kvmv_cfg.stream_stop = 0;
}
}
}
}
if(kvmv_cfg.cam_state == 0) {
kvmv_cfg.cam_state = 1;
kvmv_cfg.hdmi_cable_state = 1;
@@ -1740,7 +1839,7 @@ int kvmv_read_img(uint16_t _width, uint16_t _height, uint8_t _type, uint16_t _ql
*_pp_kvm_data = NULL;
pthread_mutex_unlock(&vi_mutex);
return IMG_BUFFER_FULL;
}
}
jpg_dump(p_kvmv_data, jpg);
delete jpg;
delete img;
@@ -1757,7 +1856,7 @@ int kvmv_read_img(uint16_t _width, uint16_t _height, uint8_t _type, uint16_t _ql
*_pp_kvm_data = NULL;
pthread_mutex_unlock(&vi_mutex);
return IMG_BUFFER_FULL;
}
}
// debug("[kvmv]get_save_buffer: %d \r\n", (int)(time::time_ms() - start_time));
ret = raw_to_h264(img, p_kvmv_data, maxmin_data(10000, 500, (int)_qlty));
// debug("[kvmv]venc raw_to_h264: %d \r\n", (int)(time::time_ms() - start_time));

View File

@@ -58,7 +58,7 @@ case "$1" in
kvm_vision)
Project_Name="kvm_vision"
Project_PATH="$NanoKVM_PATH/support/sg2002/kvm_vision_test"
echo -e "\e[36m[build] \e[32mProject: kvm_system \e[0m"
echo -e "\e[36m[build] \e[32mProject: kvm_vision \e[0m"
;;
*)
echo -e "\e[36m[build] \e[31mInvalid project name \e[0m"
@@ -162,7 +162,13 @@ then
if [ -e $SOURCE_PATH ]
then
cp -rf $SOURCE_PATH $TARGET_PATH
echo -e "\e[36m[build] \e[32mkvm_system has been moved to kvmapp.\e[0m"
if [ "$Project_Name" = "kvm_system" ]
then
echo -e "\e[36m[build] \e[32mkvm_system has been moved to kvmapp.\e[0m"
else
echo -e "\e[36m[build] \e[32mkvm_vision has been moved to kvmapp.\e[0m"
fi
else
echo -e "\e[36m[build] \e[31mThe source file does not exist. Please compile using \e[34m./build kvm_system \e[31mfirst.\e[0m"
fi

File diff suppressed because it is too large Load Diff

View File

@@ -356,8 +356,8 @@ void OLED_ShowStringTurn(uint8_t x, uint8_t y, char *chr, uint8_t sizey);
void OLED_ShowStringtoend(uint8_t x, uint8_t y, char *chr, uint8_t sizey, uint8_t end);
void OLED_ShowString_AlignRight(uint8_t x_end, uint8_t y, char *chr, uint8_t size);
void OLED_ROW(uint8_t _EN);
void OLED_ShowKVMLogo();
void OLED_ShowLogo();
void OLED_ShowSipeedLogo();
void OLED_Showline();
void OLED_Showline_1();
void OLED_ShowIMG(uint8_t x,uint8_t y,char *chr,uint8_t width,uint8_t height);

View File

@@ -13,8 +13,8 @@ void kvm_init_cube_ui(void)
OLED_Clear();
// OLED_Revolve();
OLED_ShowKVMLogo();
OLED_ShowLogo();
OLED_ShowSipeedLogo();
OLED_ShowKVMState(HDMI_STATE, 0);
OLED_ShowKVMState(HID_STATE, 0);
OLED_ShowKVMState(ETH_STATE, 0);
@@ -37,7 +37,7 @@ void kvm_init_pcie_ui(void)
{
OLED_Revolve();
OLED_Showline_1();
OLED_ShowSipeedLogo();
OLED_ShowLogo();
OLED_ShowKVMState(HDMI_STATE, 0);
OLED_ShowKVMState(HID_STATE, 0);
OLED_ShowKVMState(ETH_STATE, 0);
@@ -209,7 +209,14 @@ void kvm_eth_state_disp(ip_addr_t _ip_type, uint8_t first_disp)
(_ip_type_old != _ip_type) ||
first_disp || ip_changed(ETH_IP))
{
kvm_oled_state.eth_state = kvm_sys_state.eth_state;
// ensure eth_addr is updated
if (kvm_sys_state.eth_state >= 2) {
if (kvm_sys_state.eth_addr[0] != 0)
kvm_oled_state.eth_state = kvm_sys_state.eth_state;
} else {
kvm_oled_state.eth_state = kvm_sys_state.eth_state;
}
_ip_type_old = _ip_type;
switch(kvm_oled_state.eth_state){
case -1:
@@ -412,8 +419,15 @@ void show_wifi_config_ip(void)
{
OLED_Clear();
get_ip_addr(WiFi_IP);
OLED_ShowString(0, 1, "Config IP:", 8);
OLED_ShowString_AlignRight(63, 2, (char*)kvm_sys_state.wifi_addr, 4);
OLED_ShowString(1, 0, "Config URL", 8);
OLED_ShowString_AlignRight(63, 1, "----------------", 4);
// OLED_ShowString_AlignRight(63, 2, (char*)kvm_sys_state.wifi_addr, 4);
static char wifi_addr_with_path[30];
static char wifi_addr_with_key[30];
sprintf(wifi_addr_with_path, "%s/#/", kvm_sys_state.wifi_addr);
sprintf(wifi_addr_with_key, "WIFI?P=%s", kvm_sys_state.wifi_ap_pass);
OLED_ShowString_AlignRight(63, 2, wifi_addr_with_path, 4);
OLED_ShowString_AlignRight(63, 3, wifi_addr_with_key, 4);
}
void show_wifi_config_QR(void)
@@ -421,8 +435,7 @@ void show_wifi_config_QR(void)
static char cmd[70];
OLED_Clear();
get_ip_addr(WiFi_IP);
printf("http://%s/#/wifi\n", kvm_sys_state.wifi_addr);
sprintf(cmd, "http://%s/#/wifi", kvm_sys_state.wifi_addr);
sprintf(cmd, "http://%s/#/WIFI?P=%s", kvm_sys_state.wifi_addr, kvm_sys_state.wifi_ap_pass);
qrencode(cmd);
}

View File

@@ -17,7 +17,7 @@ uint8_t get_hdmi_version()
fread(RW_Data, sizeof(char), 2, fp);
fclose(fp);
if(RW_Data[0] == 'u'){
// 6911uxc
// 6911uxc / 6911uxe
if(RW_Data[1] == 'e'){
return 2;
} else if(RW_Data[1] == 'x') {
@@ -25,6 +25,9 @@ uint8_t get_hdmi_version()
} else {
return 1;
}
} else if(RW_Data[0] == 'd'){
// 6911d
return 3;
} else {
// 6911c
return 0;
@@ -53,6 +56,7 @@ void Production_testing_patch(void)
void new_app_init(void)
{
// Update the necessary scripts
system("rm -f /boot/logo.jpeg");
system("cp -f /kvmapp/system/update-nanokvm.py /etc/kvm/");
system("rm -f /etc/init.d/S02udisk");
system("cp -f /kvmapp/system/init.d/S00kmod /etc/init.d/");
@@ -66,6 +70,11 @@ void new_app_init(void)
} else {
system("rm -f /etc/init.d/S30wifi");
}
// if exit /etc/init.d/S98tailscaled then cp -f /kvmapp/system/init.d/S98tailscaled /etc/init.d/
if(access("/etc/init.d/S98tailscaled", F_OK) == 0){
system("cp -f /kvmapp/system/init.d/S98tailscaled /etc/init.d/");
}
// rmmod soph_saradc
system("rmmod soph_saradc");
@@ -133,6 +142,7 @@ void new_app_init(void)
fclose(fp);
if(RW_Data[0] == 'c') hdmi_ver = 1;
else if(RW_Data[0] == 'u') hdmi_ver = 2;
else if(RW_Data[0] == 'd') hdmi_ver = 2;
}
// system("/etc/init.d/S03usbdev stop_start");
@@ -189,4 +199,4 @@ void new_img_init(void)
{
build_complete_resolv();
system("rm /kvmapp/kvm_new_img");
}
}

View File

@@ -0,0 +1,605 @@
from PIL import Image
import numpy as np
import sys
import os
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, Button, Input, Label, Static
from textual.containers import Container, Horizontal, Vertical, Center, Middle
from textual.binding import Binding
from textual import events
from textual.message import Message
# Language dictionary for i18n support
LANGUAGES = {
'zh': {
'app_title': '🖼️ [ NanoKVM Logo 生成器 ]',
'app_title_editor': '✏️ NanoKVM Logo 编辑器',
'contrast_minus': '对比度-',
'contrast_plus': '对比度+',
'current_contrast': '当前对比度:{}',
'click_hint': '💡 点击图片以修改像素',
'btn_invert': '🔃 反转',
'btn_reset': '🔄 重置',
'btn_export': '💾 导出',
'btn_quit': '❌ 退出',
'help_text_with_image': '快捷键:↑↓ 调整±10 | ←→ 调整±1 | I 反转 | R 重置 | D 导出 | Q 退出',
'help_text_editor': '快捷键:I 反转 | R 清空 | D 导出 | Q 退出',
'status_blank_canvas': '空白画布 - 点击像素开始绘制',
'status_loaded': '已加载:{}',
'status_load_failed': '无法加载图片:{}',
'status_contrast': '对比度:{}',
'status_inverted': '已反转所有像素',
'status_reset_image': '已重置为原始图片',
'status_reset_canvas': '已清空画布',
'status_export_error': '错误:没有可导出的数据',
'status_exported': '✅ 已导出 logo 文件',
'status_pixel_toggled': '切换像素 ({}, {})',
'lang_select_title': '选择语言 / Select Language',
'btn_chinese': '中文 (Chinese)',
'btn_english': 'English',
'binding_quit': '退出',
'binding_export': '导出',
'binding_reset': '重置',
'binding_invert': '反转',
'binding_threshold_up': '阈值 +10',
'binding_threshold_down': '阈值 -10',
'binding_threshold_up_small': '阈值 +1',
'binding_threshold_down_small': '阈值 -1',
},
'en': {
'app_title': '🖼️ [ NanoKVM Logo Generator ]',
'app_title_editor': '✏️ NanoKVM Logo Editor',
'contrast_minus': 'Contrast-',
'contrast_plus': 'Contrast+',
'current_contrast': 'Contrast: {}',
'click_hint': '💡 Click image to toggle pixels',
'btn_invert': '🔃 Invert',
'btn_reset': '🔄 Reset',
'btn_export': '💾 Export',
'btn_quit': '❌ Quit',
'help_text_with_image': 'Shortcuts: ↑↓ ±10 | ←→ ±1 | I Invert | R Reset | D Export | Q Quit',
'help_text_editor': 'Shortcuts: I Invert | R Clear | D Export | Q Quit',
'status_blank_canvas': 'Blank canvas - Click pixels to draw',
'status_loaded': 'Loaded: {}',
'status_load_failed': 'Failed to load image: {}',
'status_contrast': 'Contrast: {}',
'status_inverted': 'All pixels inverted',
'status_reset_image': 'Reset to original image',
'status_reset_canvas': 'Canvas cleared',
'status_export_error': 'Error: No data to export',
'status_exported': '✅ Exported logo file',
'status_pixel_toggled': 'Toggled pixel ({}, {})',
'lang_select_title': '选择语言 / Select Language',
'btn_chinese': '中文 (Chinese)',
'btn_english': 'English',
'binding_quit': 'Quit',
'binding_export': 'Export',
'binding_reset': 'Reset',
'binding_invert': 'Invert',
'binding_threshold_up': 'Threshold +10',
'binding_threshold_down': 'Threshold -10',
'binding_threshold_up_small': 'Threshold +1',
'binding_threshold_down_small': 'Threshold -1',
}
}
def reverse_byte(byte):
"""Reverse the bits in a byte"""
result = 0
for i in range(8):
if (byte >> i) & 1:
result |= (1 << (7 - i))
return result
def process_image(image_path, threshold=128):
"""
Process image to 16x16 binary data
Args:
image_path: Path to the image file
threshold: Binarization threshold (0-255)
Returns:
16x16 numpy array of binary values
"""
if not os.path.exists(image_path):
return None
try:
img = Image.open(image_path).convert('L')
except Exception as e:
print(f"Error: Failed to open image - {e}")
return None
# If not square, crop the center region
if img.width != img.height:
size = min(img.width, img.height)
left = (img.width - size) // 2
top = (img.height - size) // 2
img = img.crop((left, top, left + size, top + size))
# Resize to 16x16 and binarize
img = img.resize((16, 16), Image.Resampling.LANCZOS)
binary = (np.array(img) < threshold).astype(np.uint8)
return binary
def create_blank_image():
"""Create a blank 16x16 image (all black)"""
return np.zeros((16, 16), dtype=np.uint8)
def binary_to_bytes(binary):
"""Convert 16x16 binary data to 32 bytes"""
data = []
for x in range(16):
for page in range(2):
byte = 0
start_row = page * 8
for bit in range(8):
row = start_row + bit
if row < 16 and binary[row][x] == 1:
byte |= (1 << (7 - bit))
data.append(reverse_byte(byte))
return bytes(data)
def binary_to_image(binary_data, size=64):
"""
Convert 16x16 binary data to 64x64 RGBA image
Args:
binary_data: 16x16 numpy array of binary values
size: Size to scale the image to (default 64)
Returns:
PIL Image in RGBA mode
"""
# Create 16x16 RGBA image from binary data
img_16 = Image.new('RGBA', (16, 16), (0, 0, 0, 255)) # Black for 1
pixels = img_16.load()
for y in range(16):
for x in range(16):
if binary_data[y][x] == 1:
pixels[x, y] = (255, 255, 255, 255) # White
else:
pixels[x, y] = (0, 0, 0, 255) # Black
# Scale to 64x64 using NEAREST for pixel-perfect scaling
img_64 = img_16.resize((size, size), Image.Resampling.NEAREST)
return img_64
def image_to_ico(image_path=None, binary_data=None, output_path="logo.ico", size=64):
"""
Convert image or binary data to 64x64 32-bit ICO file
Args:
image_path: Path to the source image (optional if binary_data is provided)
binary_data: 16x16 numpy array of binary values (optional if image_path is provided)
output_path: Output ICO file path
size: Size to scale the icon to (default 64)
"""
try:
if image_path and os.path.exists(image_path):
img = Image.open(image_path).convert('RGBA')
# If not square, crop the center region
if img.width != img.height:
min_size = min(img.width, img.height)
left = (img.width - min_size) // 2
top = (img.height - min_size) // 2
img = img.crop((left, top, left + min_size, top + min_size))
# Resize to 64x64 using LANCZOS for high quality
img_64 = img.resize((size, size), Image.Resampling.LANCZOS)
elif binary_data is not None:
# Convert binary data to image and scale
img_64 = binary_to_image(binary_data, size)
else:
print("Error: No image source provided")
return False
except Exception as e:
print(f"Error: Failed to create ICO - {e}")
return False
# Save as ICO with 32-bit color depth
img_64.save(output_path, format='ICO', sizes=[(size, size)])
return True
class PixelGrid(Static):
"""16x16 pixel grid component"""
class PixelClick(Message):
def __init__(self, x: int, y: int):
super().__init__()
self.x = x
self.y = y
DEFAULT_CSS = """
PixelGrid {
width: auto;
height: auto;
padding: 0;
background: $surface;
}
"""
def __init__(self, binary_data=None, id=None):
super().__init__(id=id)
self.binary_data = binary_data if binary_data is not None else np.zeros((16, 16), dtype=np.uint8)
self.can_focus = True
def update_data(self, binary_data):
self.binary_data = binary_data
self.refresh()
def render(self):
"""Render the pixel grid"""
lines = []
# Top border
lines.append("┌" + "─" * 32 + "┐")
for y in range(16):
line = "│"
for x in range(16):
if self.binary_data[y][x] == 1:
line += "██"
else:
line += " "
line += "│"
lines.append(line)
# Bottom border
lines.append("└" + "─" * 32 + "┘")
return "\n".join(lines)
def on_click(self, event: events.Click):
"""Handle click event"""
# Get coordinates relative to the component
# Use event.offset to get coordinates relative to the component
try:
local_x = event.offset_x
local_y = event.offset_y
except AttributeError:
# If offset attribute doesn't exist, use event coordinates
local_x = event.x if hasattr(event, 'x') else 0
local_y = event.y if hasattr(event, 'y') else 0
# Render format:
# Row 0: ┌────────────────────────────────┐ (top border)
# Rows 1-16: │██ ██ ... │ (pixel rows, 16 pixels per row, 2 chars per pixel)
# Row 17: └────────────────────────────────┘ (bottom border)
# Column positions: 0 is left border │, 1-32 is pixel area (2 chars per pixel), 33 is right border │
# Check if within pixel grid area (y from 1 to 16, since row 0 is top border)
if local_y >= 1 and local_y <= 16:
grid_y = local_y - 1
# Check if x is within valid range (1 to 32, since there are borders on both sides)
if local_x >= 1 and local_x <= 32:
# Each pixel takes 2 chars, (1,2) is column 0, (3,4) is column 1, etc.
grid_x = (local_x - 1) // 2
if 0 <= grid_x < 16 and 0 <= grid_y < 16:
self.post_message(self.PixelClick(grid_x, grid_y))
class LanguageSelectApp(App):
"""Language selection screen"""
CSS = """
Screen {
background: $surface;
}
#lang-container {
width: 100%;
height: 100%;
content-align: center middle;
}
#lang-title {
text-align: center;
text-style: bold;
padding: 2 0;
}
#lang-buttons {
width: auto;
height: auto;
content-align: center middle;
}
Button {
margin: 1 2;
min-width: 20;
}
"""
def __init__(self):
super().__init__()
self.selected_language = 'zh' # Default to Chinese
def compose(self) -> ComposeResult:
with Container(id="lang-container"):
yield Label("选择语言 / Select Language", id="lang-title")
with Horizontal(id="lang-buttons"):
yield Button("中文 (Chinese)", id="btn-chinese", variant="primary")
yield Button("English", id="btn-english", variant="success")
def on_button_pressed(self, event: Button.Pressed):
if event.button.id == "btn-chinese":
self.selected_language = 'zh'
self.exit('zh')
elif event.button.id == "btn-english":
self.selected_language = 'en'
self.exit('en')
class LogoApp(App):
"""Logo conversion TUI application"""
CSS = """
Screen {
background: $surface;
}
#main-container {
width: 100%;
height: 100%;
padding: 1 2;
}
#title {
text-align: center;
text-style: bold;
padding: 1 0;
}
#threshold-container {
width: 100%;
height: auto;
padding: 1 2;
content-align: center middle;
}
#threshold-value {
text-align: center;
padding: 0 1;
width: 20;
}
#button-container {
width: 100%;
height: auto;
content-align: center middle;
padding: 1 0;
}
Button {
margin: 0 1;
}
#status {
text-align: center;
padding: 1 0;
text-style: italic;
}
#help-text {
text-align: center;
padding: 1 0;
color: $text-muted;
}
"""
BINDINGS = [
Binding("q", "quit", "Quit"),
Binding("d", "export", "Export"),
Binding("r", "reset", "Reset"),
Binding("i", "invert", "Invert"),
Binding("up", "threshold_up", "Threshold +10"),
Binding("down", "threshold_down", "Threshold -10"),
Binding("left", "threshold_down_small", "Threshold -1"),
Binding("right", "threshold_up_small", "Threshold +1"),
]
def __init__(self, image_path=None, language='zh'):
super().__init__()
self.image_path = image_path
self.language = language
self.lang = LANGUAGES[language]
self.original_binary = None
self.current_binary = None
self.threshold = 128
self.has_image = image_path is not None
# Update bindings with selected language
self.BINDINGS = [
Binding("q", "quit", self.lang['binding_quit']),
Binding("d", "export", self.lang['binding_export']),
Binding("r", "reset", self.lang['binding_reset']),
Binding("i", "invert", self.lang['binding_invert']),
Binding("up", "threshold_up", self.lang['binding_threshold_up']),
Binding("down", "threshold_down", self.lang['binding_threshold_down']),
Binding("left", "threshold_down_small", self.lang['binding_threshold_down_small']),
Binding("right", "threshold_up_small", self.lang['binding_threshold_up_small']),
]
def compose(self) -> ComposeResult:
yield Header()
with Container(id="main-container"):
if self.has_image:
yield Label(self.lang['app_title'], id="title")
# Contrast control area: [Contrast-] Current value [Contrast+]
with Horizontal(id="threshold-container"):
yield Button(self.lang['contrast_minus'], id="threshold-minus-btn", variant="primary")
yield Label(self.lang['current_contrast'].format(self.threshold), id="threshold-value-label")
yield Button(self.lang['contrast_plus'], id="threshold-plus-btn", variant="primary")
else:
yield Label(self.lang['app_title_editor'], id="title")
yield PixelGrid(id="pixel-grid")
yield Label(self.lang['click_hint'], id="click-hint")
# Action button area: Invert | Reset | Export | Quit
with Horizontal(id="button-container"):
yield Button(self.lang['btn_invert'], id="invert-btn", variant="default")
yield Button(self.lang['btn_reset'], id="reset-btn", variant="warning")
yield Button(self.lang['btn_export'], id="export-btn", variant="success")
yield Button(self.lang['btn_quit'], id="quit-btn", variant="error")
yield Label("", id="status")
if self.has_image:
yield Label(self.lang['help_text_with_image'], id="help-text")
else:
yield Label(self.lang['help_text_editor'], id="help-text")
yield Footer()
def on_mount(self):
"""When app starts"""
self.pixel_grid = self.query_one("#pixel-grid", PixelGrid)
self.threshold_value_label = self.query_one("#threshold-value-label", Label) if self.has_image else None
self.status = self.query_one("#status", Label)
if self.has_image:
self.load_image(self.image_path)
else:
self.current_binary = create_blank_image()
self.pixel_grid.update_data(self.current_binary)
self.status.update(self.lang['status_blank_canvas'])
def load_image(self, path):
"""Load image"""
self.original_binary = process_image(path, self.threshold)
if self.original_binary is not None:
self.current_binary = self.original_binary.copy()
self.pixel_grid.update_data(self.current_binary)
self.status.update(self.lang['status_loaded'].format(path))
else:
self.status.update(self.lang['status_load_failed'].format(path))
def update_threshold(self, new_value: int):
"""Update threshold"""
new_value = max(0, min(255, new_value))
if new_value != self.threshold:
self.threshold = new_value
if self.threshold_value_label:
self.threshold_value_label.update(self.lang['current_contrast'].format(self.threshold))
if self.original_binary is not None:
self.current_binary = process_image(self.image_path, self.threshold)
self.pixel_grid.update_data(self.current_binary)
self.status.update(self.lang['status_contrast'].format(self.threshold))
def on_button_pressed(self, event: Button.Pressed):
"""Button press event"""
button_id = event.button.id
if button_id == "invert-btn":
self.action_invert()
elif button_id == "reset-btn":
self.action_reset()
elif button_id == "export-btn":
self.action_export()
elif button_id == "quit-btn":
self.action_quit()
elif button_id == "threshold-plus-btn":
self.action_threshold_up()
elif button_id == "threshold-minus-btn":
self.action_threshold_down()
def on_pixel_grid_pixel_click(self, event: PixelGrid.PixelClick):
"""Pixel click event"""
if self.current_binary is not None:
# Toggle pixel value
self.current_binary[event.y][event.x] = 1 - self.current_binary[event.y][event.x]
self.pixel_grid.update_data(self.current_binary)
self.status.update(self.lang['status_pixel_toggled'].format(event.x, event.y))
def action_invert(self):
"""Invert all pixels"""
if self.current_binary is not None:
self.current_binary = 1 - self.current_binary
self.pixel_grid.update_data(self.current_binary)
self.status.update(self.lang['status_inverted'])
def action_reset(self):
"""Reset to original image or clear"""
if self.has_image and self.original_binary is not None:
self.current_binary = self.original_binary.copy()
self.pixel_grid.update_data(self.current_binary)
self.status.update(self.lang['status_reset_image'])
else:
self.current_binary = create_blank_image()
self.pixel_grid.update_data(self.current_binary)
self.status.update(self.lang['status_reset_canvas'])
def action_export(self):
"""Export logo.bin and logo.ico"""
if self.current_binary is None:
self.status.update(self.lang['status_export_error'])
return
data = binary_to_bytes(self.current_binary)
with open("logo.bin", "wb") as f:
f.write(data)
# Export 64x64 32-bit ICO file
if self.has_image and self.image_path:
image_to_ico(self.image_path, "logo.ico", size=64)
else:
# Export from current canvas (16x16 -> 64x64)
image_to_ico(binary_data=self.current_binary, output_path="logo.ico", size=64)
self.status.update(self.lang['status_exported'])
def action_threshold_up(self):
"""Increase threshold by 10"""
if self.has_image:
self.update_threshold(self.threshold + 10)
def action_threshold_down(self):
"""Decrease threshold by 10"""
if self.has_image:
self.update_threshold(self.threshold - 10)
def action_threshold_up_small(self):
"""Increase threshold by 1"""
if self.has_image:
self.update_threshold(self.threshold + 1)
def action_threshold_down_small(self):
"""Decrease threshold by 1"""
if self.has_image:
self.update_threshold(self.threshold - 1)
def action_quit(self):
self.exit()
def main():
image_path = None
# First, show language selection screen
lang_app = LanguageSelectApp()
selected_language = lang_app.run()
if len(sys.argv) >= 2:
image_path = sys.argv[1]
if not os.path.exists(image_path):
print(f"Error: File not found - {image_path}")
return
app = LogoApp(image_path, language=selected_language)
app.run()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,39 @@
# NanoKVM Logo Generator
**Compatible Hardware:** NanoKVM-Cube, NanoKVM-PCIe
🛠️ **Tool Description:**
This is a logo generator for the NanoKVM OLED display and Web. You can use it to create a `logo.bin` and `logo.ico` file and place them in the `/boot` directory of your NanoKVM system.
📋 **Instructions:**
1. **Install required libraries:**
```bash
pip install Pillow numpy textual
```
2. **Prepare your logo image:**
Make sure it's as square as possible for best results 🖼️
3. **Run the script:**
```bash
python logo_generator.py path/to/your_logo.png
```
4. **Choose your language:**
Select either English or Chinese
5. **Adjust the contrast:**
Fine-tune the contrast based on the preview below until you're happy with how it looks ✨
6. **Fine-tune pixel by pixel:**
Click on individual pixels to adjust details. If you prefer an inverted display, just click the invert button 🔄
7. **Export and deploy:**
Export the `logo.bin` & `logo.ico`, then copy it to the `/boot` directory of your NanoKVM system. You can do this by:
* Enabling SSH and using SCP to copy the file 💻
* Or directly placing it in the boot partition of your TF card 💾
8. **Reboot and enjoy:**
Make sure your NanoKVM application version is greater than 2.3.6 ✅
Reboot your NanoKVM, and you should see your custom logo on the OLED display! 🎉

View File

@@ -47,9 +47,9 @@ int check_edid(uint8_t *edid_data, uint16_t edid_size)
}
// Check EDID header
if (edid_data[0] != 0x00 || edid_data[1] != 0xFF ||
edid_data[2] != 0xFF || edid_data[3] != 0xFF ||
edid_data[4] != 0xFF || edid_data[5] != 0xFF ||
if (edid_data[0] != 0x00 || edid_data[1] != 0xFF ||
edid_data[2] != 0xFF || edid_data[3] != 0xFF ||
edid_data[4] != 0xFF || edid_data[5] != 0xFF ||
edid_data[6] != 0xFF || edid_data[7] != 0x00) {
fprintf(stderr, "EDID header is invalid\n");
return -1; // EDID header is invalid
@@ -64,7 +64,7 @@ int check_edid(uint8_t *edid_data, uint16_t edid_size)
if (checksum1 != edid_data[127]) {
// Checksum for first 128 bytes is incorrect
fprintf(stderr, "Checksum for first 128 bytes is incorrect\n");
return -1;
return -1;
}
// Second 128 Bytes checksum
@@ -95,7 +95,7 @@ void NanoKVM_PCIe_HDMI_Reset(void)
// =======================================================================================================
// I2C write function
int i2c_write_byte(uint8_t offset, uint8_t reg, uint8_t data)
int i2c_write_byte(uint8_t offset, uint8_t reg, uint8_t data)
{
// reg buffer
uint8_t reg_buf[2] = {0};
@@ -121,7 +121,7 @@ int i2c_write_byte(uint8_t offset, uint8_t reg, uint8_t data)
return 0;
}
int i2c_write_bytes(uint8_t offset, uint8_t reg, const uint8_t *data, size_t len)
int i2c_write_bytes(uint8_t offset, uint8_t reg, const uint8_t *data, size_t len)
{
// check len
if (len == 0) {
@@ -159,7 +159,7 @@ int i2c_write_bytes(uint8_t offset, uint8_t reg, const uint8_t *data, size_t len
}
// I2C read function
int i2c_read_byte(uint8_t offset, uint8_t reg, uint8_t *data)
int i2c_read_byte(uint8_t offset, uint8_t reg, uint8_t *data)
{
// reg buffer
uint8_t reg_buf[2] = {0};
@@ -191,7 +191,7 @@ int i2c_read_byte(uint8_t offset, uint8_t reg, uint8_t *data)
return 0;
}
int i2c_read_bytes(uint8_t offset, uint8_t reg, uint8_t *data, size_t len)
int i2c_read_bytes(uint8_t offset, uint8_t reg, uint8_t *data, size_t len)
{
// 检查数据长度
if (len == 0) {
@@ -259,7 +259,7 @@ int lt6911uxc_edid_write(uint8_t *edid_data, uint16_t edid_size)
uint8_t version_str[32] = {0};
fprintf(stdout, "Writing EDID....\n");
// Start
if (i2c_write_byte(LT6911_SYS_OFFSET, 0xFF, 0x80) != 0) return -1;
lt6911_enable();
@@ -286,7 +286,7 @@ int lt6911uxc_edid_write(uint8_t *edid_data, uint16_t edid_size)
if (chip_data[0] != 0xEE) {
fprintf(stderr, "Unsupported chip version\n");
return -1;
}
}
if (i2c_write_byte(LT6911_SYS3_OFFSET, 0x08, 0xAE) != 0) return -1;
if (i2c_write_byte(LT6911_SYS3_OFFSET, 0x08, 0xEE) != 0) return -1;
// Write
@@ -373,7 +373,7 @@ int lt6911c_edid_write(uint8_t *edid_data, uint16_t edid_size)
uint8_t version_str[32] = {0};
fprintf(stdout, "Writing EDID....\n");
// Start
lt6911_enable();
if (i2c_read_bytes(LT6911_SYS4_OFFSET, 0x00, chip_data, 2) != 0) return -1;
@@ -487,9 +487,161 @@ int lt6911c_edid_read(uint8_t *edid_data, uint16_t edid_size)
return 0;
}
int lt6911d_str_read(uint8_t *str)
{
fprintf(stdout, "Reading LT6911D version data...\n");
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0xEE, 0x01) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5E, 0xDF) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x58, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x59, 0x50) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x10) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x58, 0x21) != 0) return -1;
if (i2c_write_byte(LT6911D_DATA_OFFSET, 0x03, 0xBF) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_DATA_OFFSET, 0x03, 0xFF) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x04) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5E, 0x5F) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x20) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5B, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5C, 0x81) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5D, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x10) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x00) != 0) return -1;
if (i2c_read_bytes(LT6911D_MANAGE_OFFSET, 0x5F, str, LT6911D_WR_SIZE) != 0) return -1;
usleep(10000);
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x08) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x58, 0x00) != 0) return -1;
return 0;
}
int lt6911d_edid_write(uint8_t *edid_data, uint16_t edid_size)
{
uint8_t i;
uint8_t wr_count = edid_size / LT6911D_WR_SIZE + 1;
uint8_t version_str[LT6911D_WR_SIZE] = {0};
if (lt6911d_str_read(version_str) != 0) {
fprintf(stderr, "Failed to read LT6911D version data\n");
return -1;
}
fprintf(stdout, "Writing EDID....\n");
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0xEE, 0x01) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5E, 0xDF) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x58, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x59, 0x50) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x10) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x58, 0x21) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x04) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5B, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5C, 0x80) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5D, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x01) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x00) != 0) return -1;
usleep(600000);
for (i = 0; i < wr_count; i++) {
if (i2c_write_byte(LT6911D_DATA_OFFSET, 0x03, 0xBF) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_DATA_OFFSET, 0x03, 0xFF) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x04) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5E, 0xDF) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x20) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x58, 0x21) != 0) return -1;
if (i != wr_count - 1) {
if (i2c_write_bytes(LT6911D_MANAGE_OFFSET, 0x59, edid_data + (LT6911D_WR_SIZE * i), LT6911D_WR_SIZE) != 0) return -1;
} else {
if (i2c_write_bytes(LT6911D_MANAGE_OFFSET, 0x59, version_str, LT6911D_WR_SIZE) != 0) return -1;
}
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5B, 0x00) != 0) return -1;
if (i != wr_count - 1) {
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5C, 0x80) != 0) return -1;
} else {
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5C, 0x81) != 0) return -1;
}
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5D, 0x00 + (LT6911D_WR_SIZE * i)) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x10) != 0) return -1;
usleep(5000);
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x00) != 0) return -1;
usleep(10000);
}
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x08) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x58, 0x00) != 0) return -1;
fprintf(stdout, "EDID write completed\n");
return 0;
}
int lt6911d_edid_read(uint8_t *edid_data, uint16_t edid_size)
{
uint8_t i;
uint8_t wr_count = edid_size / LT6911D_WR_SIZE;
fprintf(stdout, "Reading EDID...\n");
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0xEE, 0x01) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5E, 0xDF) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x58, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x59, 0x50) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x10) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x58, 0x21) != 0) return -1;
for (i = 0; i < wr_count; i++) {
if (i2c_write_byte(LT6911D_DATA_OFFSET, 0x03, 0xBF) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_DATA_OFFSET, 0x03, 0xFF) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x04) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5E, 0x5F) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x20) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5B, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5C, 0x80) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5D, 0x00 + (LT6911D_WR_SIZE * i)) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x10) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x00) != 0) return -1;
if (i2c_read_bytes(LT6911D_MANAGE_OFFSET, 0x5F, edid_data + (LT6911D_WR_SIZE * i), LT6911D_WR_SIZE) != 0) return -1;
usleep(10000);
}
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x08) != 0) return -1;
usleep(1000);
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x5A, 0x00) != 0) return -1;
if (i2c_write_byte(LT6911D_MANAGE_OFFSET, 0x58, 0x00) != 0) return -1;
return 0;
}
// =======================================================================================================
int lt6911_edid_config(chip_version_t chip_version, uint8_t *edid_data, uint16_t edid_size)
int lt6911_edid_config(chip_version_t chip_version, uint8_t *edid_data, uint16_t edid_size)
{
if (chip_version == CHIP_UNKNOWN)
{
@@ -523,6 +675,12 @@ int lt6911_edid_config(chip_version_t chip_version, uint8_t *edid_data, uint16_t
close(client);
return -1;
}
} else if (chip_version == CHIP_LT6911D) {
if (lt6911d_edid_write(edid_data, edid_size) != 0) {
fprintf(stderr, "Failed to write EDID data to LT6911D\n");
close(client);
return -1;
}
} else {
fprintf(stderr, "Unknown chip version\n");
close(client);
@@ -545,6 +703,12 @@ int lt6911_edid_config(chip_version_t chip_version, uint8_t *edid_data, uint16_t
close(client);
return -1;
}
} else if (chip_version == CHIP_LT6911D) {
if (lt6911d_edid_read(edid_read_data, EDID_BUFFER_SIZE) != 0) {
fprintf(stderr, "Failed to read EDID data from LT6911D\n");
close(client);
return -1;
}
} else {
fprintf(stderr, "Unknown chip version\n");
close(client);
@@ -596,7 +760,7 @@ void print_warning(product_version_t product_version) {
printf("Please ensure you can physically disconnect its power,\n");
printf("NOT just remotely reboot it!!\n");
printf("==========================================================\n\n");
}
}
}
void print_success(product_version_t product_version) {
@@ -604,31 +768,31 @@ void print_success(product_version_t product_version) {
printf("✅ EDID update successful!\n");
if (product_version == PRODUCT_CUBE_A || product_version == PRODUCT_CUBE_B) {
printf("Please manually power cycle the device to apply changes.\n");
}
}
printf("=========================================================\n\n");
}
int get_user_confirmation() {
char input[256];
printf("Do you want to continue? (Y/N): \n");
while (1) {
if (fgets(input, sizeof(input), stdin) == NULL) {
printf("\nInput error. Exiting.\n");
return 0;
}
// 去除换行符
input[strcspn(input, "\n")] = '\0';
if (strlen(input) == 0) {
continue; // 空输入,重新提示
}
// 转换为小写方便比较
char choice = tolower(input[0]);
if (choice == 'y') {
return 1;
} else if (choice == 'n') {
@@ -656,7 +820,7 @@ int main(int argc, char *argv[]) {
fprintf(stderr, "Please upgrade to the latest system\n");
return 1;
}
if (fgets(chip_version_str, sizeof(chip_version_str), file) == NULL) {
fprintf(stderr, "Failed to read chip version\n");
fclose(file);
@@ -669,23 +833,26 @@ int main(int argc, char *argv[]) {
fprintf(stderr, "Please upgrade to the latest system\n");
return 1;
}
if (fgets(product_version_str, sizeof(product_version_str), file) == NULL) {
fprintf(stderr, "Failed to read product version\n");
fclose(file);
return 1;
}
fclose(file);
chip_version_str[strcspn(chip_version_str, "\n")] = '\0';
product_version_str[strcspn(product_version_str, "\n")] = '\0';
if (strcmp(chip_version_str, "c") == 0) {
fprintf(stdout, "Chip Version: LT6911C\n");
chip_version = CHIP_LT6911C;
} else if (strcmp(chip_version_str, "ux") == 0) {
fprintf(stdout, "Chip Version: LT6911UXC\n");
chip_version = CHIP_LT6911UXC;
} else if (strcmp(chip_version_str, "d") == 0) {
fprintf(stdout, "Chip Version: LT6911D\n");
chip_version = CHIP_LT6911D;
} else if (strcmp(chip_version_str, "ue") == 0) {
fprintf(stderr, "Chip Version Error: UE version's edid can't be updated\n");
return 1;
@@ -693,7 +860,7 @@ int main(int argc, char *argv[]) {
fprintf(stderr, "Chip Version Error: Unknown version\n");
return 1;
}
if (strcmp(product_version_str, "alpha") == 0) {
fprintf(stdout, "Product Version: CUBE_A\n");
product_version = PRODUCT_CUBE_A;

View File

@@ -17,10 +17,13 @@
#define LT6911C_HDMI_INFO_OFFSET 0xD2 // LT6911C HDMI信息寄存器偏移地址
#define LT6911C_AUDIO_INFO_OFFSET 0xD1 // LT6911C 音频信息寄存器偏移地址
#define LT6911C_CSI_INFO_OFFSET 0xC2 // LT6911C CSI信息寄存器偏移地址
#define LT6911D_MANAGE_OFFSET 0xE0 // LT6911D 管理寄存器偏移地址
#define LT6911D_DATA_OFFSET 0xE1 // LT6911D 数据寄存器偏移地址
#define EDID_BUFFER_SIZE 256 // 最大支持的字节数
#define LT6911UXC_WR_SIZE 32 // LT6911UXC单次读写最大字节数
#define LT6911C_WR_SIZE 16 // LT6911C单次读写最大字节数
#define LT6911D_WR_SIZE 32 // LT6911D单次读写最大字节数
#define VERSION_PATH "/etc/kvm/hdmi_version"
#define PRODUCT_PATH "/etc/kvm/hw"
@@ -28,6 +31,7 @@
typedef enum {
CHIP_LT6911UXC = 0,
CHIP_LT6911C,
CHIP_LT6911D,
CHIP_UNKNOWN
} chip_version_t;
@@ -38,4 +42,4 @@ typedef enum {
PRODUCT_UNKNOWN,
} product_version_t;
#endif // NANOKVM_UPDATE_EDID_H
#endif // NANOKVM_UPDATE_EDID_H

View File

@@ -18,12 +18,12 @@
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"antd": "^5.29.3",
"axios": "1.13.6",
"axios": "^1.15.2",
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"i18next": "^23.16.8",
"jotai": "^2.18.0",
"js-cookie": "^3.0.5",
"js-cookie": "^3.0.7",
"lucide-react": "^0.562.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -31,16 +31,18 @@
"react-error-boundary": "^4.1.2",
"react-helmet-async": "^2.0.5",
"react-i18next": "^14.1.3",
"react-markdown": "^10.1.0",
"react-responsive": "^10.0.1",
"react-router-dom": "^6.30.3",
"react-simple-keyboard": "^3.8.164",
"remark-gfm": "^4.0.1",
"semver": "^7.7.4",
"vaul": "^0.9.9",
"websocket": "^1.0.35",
"yocto-queue": "^1.2.2"
},
"devDependencies": {
"@eslint/js": "^9.39.3",
"@eslint/js": "^9.39.4",
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
"@types/crypto-js": "^4.2.2",
"@types/js-cookie": "^3.0.6",
@@ -48,25 +50,26 @@
"@types/react-dom": "^18.3.7",
"@types/semver": "^7.7.1",
"@types/websocket": "^1.0.10",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1",
"@typescript-eslint/eslint-plugin": "^8.59.0",
"@typescript-eslint/parser": "^8.59.0",
"@vitejs/plugin-react": "^4.7.0",
"autoprefixer": "^10.4.27",
"eslint": "^9.39.3",
"eslint": "^9.39.4",
"eslint-config-prettier": "^9.1.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^17.3.0",
"msw": "^2.12.10",
"postcss": "^8.5.6",
"postcss": "^8.5.10",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "7.1.11",
"vite-tsconfig-paths": "^4.3.2"
"typescript-eslint": "^8.59.0",
"vite": "7.3.2",
"vite-tsconfig-paths": "^4.3.2",
"yaml": "^2.8.3"
},
"msw": {
"workerDirectory": [

1815
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

6
web/pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,6 @@
allowBuilds:
bufferutil: true
es5-ext: true
esbuild: true
msw: true
utf-8-validate: true

View File

@@ -5,24 +5,23 @@
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
* - Please do NOT serve this file on production.
*/
const PACKAGE_VERSION = '2.6.0'
const INTEGRITY_CHECKSUM = '07a8241b182f8a246a7cd39894799a9e'
const PACKAGE_VERSION = '2.12.10'
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
self.addEventListener('install', function () {
addEventListener('install', function () {
self.skipWaiting()
})
self.addEventListener('activate', function (event) {
addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
self.addEventListener('message', async function (event) {
const clientId = event.source.id
addEventListener('message', async function (event) {
const clientId = Reflect.get(event.source || {}, 'id')
if (!clientId || !self.clients) {
return
@@ -72,11 +71,6 @@ self.addEventListener('message', async function (event) {
break
}
case 'MOCK_DEACTIVATE': {
activeClientIds.delete(clientId)
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
@@ -94,69 +88,92 @@ self.addEventListener('message', async function (event) {
}
})
self.addEventListener('fetch', function (event) {
const { request } = event
addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now()
// Bypass navigation requests.
if (request.mode === 'navigate') {
if (event.request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
if (
event.request.cache === 'only-if-cached' &&
event.request.mode !== 'same-origin'
) {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been deleted (still remains active until the next reload).
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
// Generate unique request ID.
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId))
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
})
async function handleRequest(event, requestId) {
/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event)
const response = await getResponse(event, client, requestId)
const requestCloneForEvents = event.request.clone()
const response = await getResponse(
event,
client,
requestId,
requestInterceptedAt,
)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
;(async function () {
const responseClone = response.clone()
const serializedRequest = await serializeRequest(requestCloneForEvents)
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
requestId,
isMockedResponse: IS_MOCKED_RESPONSE in response,
// Clone the response so both the client and the library could consume it.
const responseClone = response.clone()
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: responseClone.type,
status: responseClone.status,
statusText: responseClone.statusText,
body: responseClone.body,
headers: Object.fromEntries(responseClone.headers.entries()),
body: responseClone.body,
},
},
[responseClone.body],
)
})()
},
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
)
}
return response
}
// Resolve the main client for the given event.
// Client that issues a request doesn't necessarily equal the client
// that registered the worker. It's with the latter the worker should
// communicate with during the response resolving phase.
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
@@ -184,20 +201,39 @@ async function resolveMainClient(event) {
})
}
async function getResponse(event, client, requestId) {
const { request } = event
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = request.clone()
const requestClone = event.request.clone()
function passthrough() {
const headers = Object.fromEntries(requestClone.headers.entries())
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)
// Remove internal MSW request header so the passthrough request
// complies with any potential CORS preflight checks on the server.
// Some servers forbid unknown request headers.
delete headers['x-msw-intention']
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete('accept')
}
}
return fetch(requestClone, { headers })
}
@@ -216,29 +252,18 @@ async function getResponse(event, client, requestId) {
}
// Notify the client that a request has been intercepted.
const requestBuffer = await request.arrayBuffer()
const serializedRequest = await serializeRequest(event.request)
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: requestBuffer,
keepalive: request.keepalive,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
[requestBuffer],
[serializedRequest.body],
)
switch (clientMessage.type) {
@@ -254,6 +279,12 @@ async function getResponse(event, client, requestId) {
return passthrough()
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
@@ -266,14 +297,18 @@ function sendToClient(client, message, transferrables = []) {
resolve(event.data)
}
client.postMessage(
message,
[channel.port2].concat(transferrables.filter(Boolean)),
)
client.postMessage(message, [
channel.port2,
...transferrables.filter(Boolean),
])
})
}
async function respondWithMock(response) {
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
@@ -291,3 +326,24 @@ async function respondWithMock(response) {
return mockedResponse
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
}
}

View File

@@ -1,5 +1,7 @@
import { http } from '@/lib/http.ts';
export type DNSMode = 'manual' | 'dhcp';
// wake on lan
export function wol(mac: string) {
const data = {
@@ -31,12 +33,29 @@ export function getWiFi() {
}
// connect wifi without auth (only available in wifi configuration mode)
export function connectWifiNoAuth(ssid: string, password: string) {
export function connectWifiNoAuth(ssid: string, password: string, apPassword?: string) {
const data = {
ssid,
password
};
return http.post('/api/network/wifi', data);
return http.post('/api/network/wifi', data, {
headers: {
'X-AP-Key': apPassword || ''
}
});
}
// verify ap login
export function verifyApLogin(apPassword: string) {
return http.post(
'/api/network/wifi/verify',
{},
{
headers: {
'X-AP-Key': apPassword || ''
}
}
);
}
// connect wifi
@@ -52,3 +71,11 @@ export function connectWifi(ssid: string, password: string) {
export function disconnectWifi() {
return http.post('/api/network/wifi/disconnect');
}
export function getDNS() {
return http.get('/api/network/dns');
}
export function setDNS(mode: DNSMode, servers: string[]) {
return http.post('/api/network/dns', { mode, servers });
}

130
web/src/api/picoclaw.ts Normal file
View File

@@ -0,0 +1,130 @@
import { http } from '@/lib/http.ts';
import {
picoclawGateway,
type GatewayRunState,
type GatewayTransportState
} from '@/lib/picoclaw-gateway.ts';
const sessionIDHeader = 'X-PicoClaw-Session-ID';
type ModelConfigRequest = {
model: string;
api_base: string;
api_key: string;
};
type AgentProfileRequest = {
profile: string;
};
export type PicoclawSessionListItem = {
id: string;
title: string;
preview: string;
message_count: number;
created: string;
updated: string;
};
export type PicoclawSessionDetailMessage = {
role: 'user' | 'assistant';
content: string;
};
export type PicoclawSessionDetail = {
id: string;
messages: PicoclawSessionDetailMessage[];
summary?: string;
created: string;
updated: string;
};
export function setPicoclawModelConfig(data: ModelConfigRequest) {
return http.post('/api/picoclaw/model/config', data);
}
export function setPicoclawAgentProfile(data: AgentProfileRequest) {
return http.post('/api/picoclaw/agent/profile', data);
}
export function listPicoclawSessions(params?: { offset?: number; limit?: number }) {
return http.get('/api/picoclaw/sessions', { params });
}
export function getPicoclawSession(id: string) {
return http.get(`/api/picoclaw/sessions/${id}`);
}
export function deletePicoclawSession(id: string) {
return http.delete(`/api/picoclaw/sessions/${id}`);
}
export function getRuntimeStatus() {
return http.get('/api/picoclaw/runtime/status');
}
export function startRuntime() {
return http.post('/api/picoclaw/runtime/start');
}
export function stopRuntime() {
return http.post('/api/picoclaw/runtime/stop');
}
export function installRuntime() {
return http.post('/api/picoclaw/runtime/install');
}
export function uninstallRuntime() {
return http.post('/api/picoclaw/runtime/uninstall');
}
export function connectGateway(sessionId?: string) {
return picoclawGateway.connect({ sessionId });
}
export function releaseRuntimeSession(sessionId?: string) {
const activeSessionId = sessionId || picoclawGateway.getSessionId();
if (!activeSessionId) {
return Promise.resolve(null);
}
return http.request({
method: 'delete',
url: '/api/picoclaw/runtime/session',
headers: {
[sessionIDHeader]: activeSessionId
}
});
}
export function sendChatMessage(
content: string,
options?: { id?: string; maxSteps?: number; maxRuntimeMs?: number; trackState?: boolean }
) {
return picoclawGateway.sendChatMessage(content, options);
}
export function sendStopMessage() {
return picoclawGateway.sendStopMessage();
}
export async function closeGateway() {
const activeSessionId = picoclawGateway.getSessionId();
picoclawGateway.close();
if (!activeSessionId) {
return;
}
await releaseRuntimeSession(activeSessionId).catch(() => undefined);
}
export function onGatewayConnectionState(listener: (state: GatewayTransportState) => void) {
return picoclawGateway.on('transport_state', listener);
}
export function onGatewayRunState(listener: (state: GatewayRunState) => void) {
return picoclawGateway.on('run_state', listener);
}
export { picoclawGateway };

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#737373" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-monitor-x"><path d="m14.5 12.5-5-5"/><path d="m9.5 12.5 5-5"/><rect width="20" height="14" x="2" y="3" rx="2"/><path d="M12 17v4"/><path d="M8 21h8"/></svg>

Before

Width:  |  Height:  |  Size: 354 B

View File

@@ -2,7 +2,8 @@
@tailwind components;
@tailwind utilities;
html, body {
html,
body {
padding: 0;
margin: 0;
background: #000;
@@ -23,7 +24,7 @@ html, body {
}
.spin {
animation: spin 1s linear;
animation: spin 1s linear;
}
@keyframes spin {
@@ -34,3 +35,32 @@ animation: spin 1s linear;
transform: rotate(360deg);
}
}
/* PicoClaw Chat Custom Scrollbar */
.picoclaw-sidebar-scrollbar *::-webkit-scrollbar {
width: 4px;
height: 4px;
}
.picoclaw-sidebar-scrollbar *::-webkit-scrollbar-track {
background: transparent;
}
.picoclaw-sidebar-scrollbar *::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 4px;
}
.picoclaw-sidebar-scrollbar *::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
/* PicoClaw Chat Loading Animation */
@keyframes picoclaw-loading-bounce {
0%,
100% {
transform: translateY(0.5px);
opacity: 0.3;
}
50% {
transform: translateY(-1.5px);
opacity: 1;
}
}

View File

@@ -0,0 +1,43 @@
import type { ReactNode, SVGProps } from 'react';
export type IconProps = Omit<SVGProps<SVGSVGElement>, 'color'> & {
size?: number | string;
color?: string;
strokeWidth?: number | string;
};
type IconBaseProps = IconProps & {
children: ReactNode;
};
export const IconBase = ({
children,
className,
size = 24,
color = 'currentColor',
strokeWidth = 2,
viewBox = '0 0 24 24',
fill = 'none',
strokeLinecap = 'round',
strokeLinejoin = 'round',
...props
}: IconBaseProps) => {
return (
<svg
aria-hidden={props['aria-label'] ? undefined : true}
className={className}
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox={viewBox}
fill={fill}
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap={strokeLinecap}
strokeLinejoin={strokeLinejoin}
{...props}
>
{children}
</svg>
);
};

View File

@@ -0,0 +1,12 @@
import { IconBase, type IconProps } from './icon-base.tsx';
export const LayoutSidebarRightCollapse = (props: IconProps) => {
return (
<IconBase {...props}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2l0 -12" />
<path d="M15 4v16" />
<path d="M9 10l2 2l-2 2" />
</IconBase>
);
};

View File

@@ -0,0 +1,18 @@
import { IconBase, type IconProps } from './icon-base.tsx';
export const Robot = (props: IconProps) => {
return (
<IconBase {...props}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M6 6a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v4a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2l0 -4" />
<path d="M12 2v2" />
<path d="M9 12v9" />
<path d="M15 12v9" />
<path d="M5 16l4 -2" />
<path d="M15 14l4 2" />
<path d="M9 18h6" />
<path d="M10 8v.01" />
<path d="M14 8v.01" />
</IconBase>
);
};

Some files were not shown because too many files have changed in this diff Show More