mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-11-11 10:29:19 -06:00
Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b2ba7c0c1 | ||
|
|
208998c13a | ||
|
|
3de4a18eb4 | ||
|
|
863fea9ab8 | ||
|
|
f65c1b06aa | ||
|
|
fe34008ce3 | ||
|
|
961d7f2e89 | ||
|
|
94749ea621 | ||
|
|
6ae742deed | ||
|
|
b653f905e4 | ||
|
|
af19795c29 | ||
|
|
be7584d908 | ||
|
|
a960417739 | ||
|
|
3f0621e7ed | ||
|
|
515c5e2a76 | ||
|
|
cb025f04b4 | ||
|
|
2ca5b19efe | ||
|
|
40f3917014 | ||
|
|
a40433522c | ||
|
|
8470b89214 | ||
|
|
5a08170518 | ||
|
|
dff794cee4 | ||
|
|
36cf9c30d8 | ||
|
|
1c43542919 | ||
|
|
8eca1edf47 | ||
|
|
4080788cab | ||
|
|
424302789e | ||
|
|
6651167dd8 | ||
|
|
2d67720022 | ||
|
|
1893912ba7 | ||
|
|
e41ce2d6b8 | ||
|
|
a5c6489f19 | ||
|
|
fafef3d89a | ||
|
|
4b5f539445 | ||
|
|
cefe0f9ab2 | ||
|
|
7f3d29eba2 | ||
|
|
64681e2aac | ||
|
|
fcb1f1c7fe | ||
|
|
aa0546ff37 | ||
|
|
3254412017 | ||
|
|
fda3c6b1a8 | ||
|
|
97832557fb | ||
|
|
590db5b08c | ||
|
|
10a512ec96 | ||
|
|
08093dddad | ||
|
|
86a85a61b7 | ||
|
|
cb3a2c562e | ||
|
|
832832a716 | ||
|
|
5fe6d0d48f | ||
|
|
67fe57f189 | ||
|
|
44caee21ab | ||
|
|
5b2a1ff788 | ||
|
|
dc5ba29bcf | ||
|
|
89c14310c7 | ||
|
|
3322a3c07f | ||
|
|
57887f3f69 | ||
|
|
2b8f640957 |
94
CHANGELOG.md
94
CHANGELOG.md
@@ -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
|
||||
|
||||
98
README.md
98
README.md
@@ -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
45
kvmapp/picoclaw/AGENT.md
Normal 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.
|
||||
46
kvmapp/picoclaw/AGENT_KVM.md
Normal file
46
kvmapp/picoclaw/AGENT_KVM.md
Normal 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.
|
||||
94
kvmapp/picoclaw/skills/kvm-control/SKILL.md
Normal file
94
kvmapp/picoclaw/skills/kvm-control/SKILL.md
Normal 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.
|
||||
1123
kvmapp/picoclaw/skills/kvm-control/scripts/nanokvm-bridge.sh
Executable file
1123
kvmapp/picoclaw/skills/kvm-control/scripts/nanokvm-bridge.sh
Executable file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
158
kvmapp/system/init.d/S96picoclaw
Executable 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
|
||||
@@ -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`
|
||||
|
||||
@@ -27,6 +27,7 @@ server
|
||||
|
||||
```yaml
|
||||
proto: http
|
||||
host: ""
|
||||
port:
|
||||
http: 80
|
||||
https: 443
|
||||
|
||||
@@ -26,6 +26,7 @@ server
|
||||
```yaml
|
||||
# 网络设置
|
||||
proto: http # 访问协议,默认为 `http`,仅当配置了证书时支持改为 `https`
|
||||
host: ""
|
||||
port:
|
||||
http: 80 # HTTP 服务的监听端口,默认为 `80`
|
||||
https: 443 # HTTPS 服务的监听端口(启用 https 协议时生效),默认为 `443`
|
||||
|
||||
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
var defaultConfig = &Config{
|
||||
Proto: "http",
|
||||
Host: "",
|
||||
Port: Port{
|
||||
Http: 80,
|
||||
Https: 443,
|
||||
|
||||
61
server/config/picoclaw_internal.go
Normal file
61
server/config/picoclaw_internal.go
Normal 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
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
94
server/middleware/loopback_http.go
Normal file
94
server/middleware/loopback_http.go
Normal 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()
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
64
server/router/picoclaw.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
|
||||
642
server/service/network/dns.go
Normal file
642
server/service/network/dns.go
Normal 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
|
||||
}
|
||||
56
server/service/network/scripts/99-nanokvm-dns
Normal file
56
server/service/network/scripts/99-nanokvm-dns
Normal 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
|
||||
@@ -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", "")
|
||||
}
|
||||
|
||||
@@ -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), " ")
|
||||
}
|
||||
|
||||
316
server/service/picoclaw/actions.go
Normal file
316
server/service/picoclaw/actions.go
Normal 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")
|
||||
}
|
||||
}
|
||||
147
server/service/picoclaw/agent_profile.go
Normal file
147
server/service/picoclaw/agent_profile.go
Normal 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()),
|
||||
})
|
||||
}
|
||||
184
server/service/picoclaw/config.go
Normal file
184
server/service/picoclaw/config.go
Normal 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
|
||||
}
|
||||
215
server/service/picoclaw/config_files.go
Normal file
215
server/service/picoclaw/config_files.go
Normal 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
|
||||
}
|
||||
71
server/service/picoclaw/errors.go
Normal file
71
server/service/picoclaw/errors.go
Normal 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)
|
||||
}
|
||||
107
server/service/picoclaw/gateway_client.go
Normal file
107
server/service/picoclaw/gateway_client.go
Normal 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)
|
||||
}
|
||||
}
|
||||
277
server/service/picoclaw/gateway_proxy.go
Normal file
277
server/service/picoclaw/gateway_proxy.go
Normal 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)
|
||||
}
|
||||
86
server/service/picoclaw/gateway_write.go
Normal file
86
server/service/picoclaw/gateway_write.go
Normal 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)
|
||||
}
|
||||
271
server/service/picoclaw/keyboard_map.go
Normal file
271
server/service/picoclaw/keyboard_map.go
Normal 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
|
||||
}
|
||||
102
server/service/picoclaw/load_image_instruction.go
Normal file
102
server/service/picoclaw/load_image_instruction.go
Normal 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
|
||||
}
|
||||
299
server/service/picoclaw/mcp_handler.go
Normal file
299
server/service/picoclaw/mcp_handler.go
Normal 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, ¶ms); 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, ¶ms)
|
||||
}
|
||||
|
||||
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, ¶ms); 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
225
server/service/picoclaw/model_config.go
Normal file
225
server/service/picoclaw/model_config.go
Normal 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
|
||||
}
|
||||
22
server/service/picoclaw/runtime_constants.go
Normal file
22
server/service/picoclaw/runtime_constants.go
Normal 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
|
||||
)
|
||||
253
server/service/picoclaw/runtime_defaults.go
Normal file
253
server/service/picoclaw/runtime_defaults.go
Normal 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()
|
||||
}
|
||||
110
server/service/picoclaw/runtime_handlers.go
Normal file
110
server/service/picoclaw/runtime_handlers.go
Normal 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(),
|
||||
})
|
||||
}
|
||||
441
server/service/picoclaw/runtime_install.go
Normal file
441
server/service/picoclaw/runtime_install.go
Normal 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
|
||||
}
|
||||
31
server/service/picoclaw/runtime_session.go
Normal file
31
server/service/picoclaw/runtime_session.go
Normal 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,
|
||||
})
|
||||
}
|
||||
228
server/service/picoclaw/runtime_start_stop.go
Normal file
228
server/service/picoclaw/runtime_start_stop.go
Normal 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
|
||||
}
|
||||
173
server/service/picoclaw/screenshot.go
Normal file
173
server/service/picoclaw/screenshot.go
Normal 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)
|
||||
}
|
||||
385
server/service/picoclaw/service.go
Normal file
385
server/service/picoclaw/service.go
Normal 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
|
||||
}
|
||||
52
server/service/picoclaw/session_cleanup.go
Normal file
52
server/service/picoclaw/session_cleanup.go
Normal 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)
|
||||
}
|
||||
543
server/service/picoclaw/session_history.go
Normal file
543
server/service/picoclaw/session_history.go
Normal 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]) + "..."
|
||||
}
|
||||
135
server/service/picoclaw/session_lock.go
Normal file
135
server/service/picoclaw/session_lock.go
Normal 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 != ""
|
||||
}
|
||||
94
server/service/picoclaw/session_manager.go
Normal file
94
server/service/picoclaw/session_manager.go
Normal 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)
|
||||
}
|
||||
217
server/service/picoclaw/types.go
Normal file
217
server/service/picoclaw/types.go
Normal 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"`
|
||||
}
|
||||
167
server/service/stream/capture_status.go
Normal file
167
server/service/stream/capture_status.go
Normal 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 "", ""
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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{}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
111
server/service/ws/capture_status.go
Normal file
111
server/service/ws/capture_status.go
Normal 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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -37,5 +37,7 @@ func (s *Service) Connect(c *gin.Context) {
|
||||
manager.AddClient(ws, client)
|
||||
defer manager.RemoveClient(ws)
|
||||
|
||||
sendCaptureStatusSnapshot(client)
|
||||
|
||||
client.Start()
|
||||
}
|
||||
|
||||
@@ -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
28
server/utils/listener.go
Normal 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()
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
605
tools/logo_generator/logo_generator.py
Normal file
605
tools/logo_generator/logo_generator.py
Normal 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()
|
||||
39
tools/logo_generator/readme.md
Normal file
39
tools/logo_generator/readme.md
Normal 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! 🎉
|
||||
Binary file not shown.
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
1815
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
6
web/pnpm-workspace.yaml
Normal file
6
web/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
allowBuilds:
|
||||
bufferutil: true
|
||||
es5-ext: true
|
||||
esbuild: true
|
||||
msw: true
|
||||
utf-8-validate: true
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
130
web/src/api/picoclaw.ts
Normal 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 };
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
43
web/src/components/icons/icon-base.tsx
Normal file
43
web/src/components/icons/icon-base.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
12
web/src/components/icons/layout-sidebar-right-collapse.tsx
Normal file
12
web/src/components/icons/layout-sidebar-right-collapse.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
18
web/src/components/icons/robot.tsx
Normal file
18
web/src/components/icons/robot.tsx
Normal 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
Reference in New Issue
Block a user