ultrabeam
This commit is contained in:
11
cmd/server/web/dist/assets/index-8_72Rq0c.js
vendored
Normal file
11
cmd/server/web/dist/assets/index-8_72Rq0c.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
cmd/server/web/dist/assets/index-Ml--d1Bc.css
vendored
Normal file
1
cmd/server/web/dist/assets/index-Ml--d1Bc.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4
cmd/server/web/dist/index.html
vendored
4
cmd/server/web/dist/index.html
vendored
@@ -7,8 +7,8 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||||
<script type="module" crossorigin src="/assets/index-CQrlLShx.js"></script>
|
<script type="module" crossorigin src="/assets/index-8_72Rq0c.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-PFp0U9rZ.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-Ml--d1Bc.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ devices:
|
|||||||
rotator_genius:
|
rotator_genius:
|
||||||
host: "10.10.10.121"
|
host: "10.10.10.121"
|
||||||
port: 9006
|
port: 9006
|
||||||
|
|
||||||
|
ultrabeam:
|
||||||
|
host: "10.10.10.124"
|
||||||
|
port: 4210
|
||||||
|
|
||||||
weather:
|
weather:
|
||||||
openweathermap_api_key: "YOUR_API_KEY_HERE"
|
openweathermap_api_key: "YOUR_API_KEY_HERE"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"git.rouggy.com/rouggy/ShackMaster/internal/devices/powergenius"
|
"git.rouggy.com/rouggy/ShackMaster/internal/devices/powergenius"
|
||||||
"git.rouggy.com/rouggy/ShackMaster/internal/devices/rotatorgenius"
|
"git.rouggy.com/rouggy/ShackMaster/internal/devices/rotatorgenius"
|
||||||
"git.rouggy.com/rouggy/ShackMaster/internal/devices/tunergenius"
|
"git.rouggy.com/rouggy/ShackMaster/internal/devices/tunergenius"
|
||||||
|
"git.rouggy.com/rouggy/ShackMaster/internal/devices/ultrabeam"
|
||||||
"git.rouggy.com/rouggy/ShackMaster/internal/devices/webswitch"
|
"git.rouggy.com/rouggy/ShackMaster/internal/devices/webswitch"
|
||||||
"git.rouggy.com/rouggy/ShackMaster/internal/services/solar"
|
"git.rouggy.com/rouggy/ShackMaster/internal/services/solar"
|
||||||
"git.rouggy.com/rouggy/ShackMaster/internal/services/weather"
|
"git.rouggy.com/rouggy/ShackMaster/internal/services/weather"
|
||||||
@@ -23,6 +24,7 @@ type DeviceManager struct {
|
|||||||
tunerGenius *tunergenius.Client
|
tunerGenius *tunergenius.Client
|
||||||
antennaGenius *antennagenius.Client
|
antennaGenius *antennagenius.Client
|
||||||
rotatorGenius *rotatorgenius.Client
|
rotatorGenius *rotatorgenius.Client
|
||||||
|
ultrabeam *ultrabeam.Client
|
||||||
solarClient *solar.Client
|
solarClient *solar.Client
|
||||||
weatherClient *weather.Client
|
weatherClient *weather.Client
|
||||||
|
|
||||||
@@ -40,6 +42,7 @@ type SystemStatus struct {
|
|||||||
TunerGenius *tunergenius.Status `json:"tuner_genius"`
|
TunerGenius *tunergenius.Status `json:"tuner_genius"`
|
||||||
AntennaGenius *antennagenius.Status `json:"antenna_genius"`
|
AntennaGenius *antennagenius.Status `json:"antenna_genius"`
|
||||||
RotatorGenius *rotatorgenius.Status `json:"rotator_genius"`
|
RotatorGenius *rotatorgenius.Status `json:"rotator_genius"`
|
||||||
|
Ultrabeam *ultrabeam.Status `json:"ultrabeam"`
|
||||||
Solar *solar.SolarData `json:"solar"`
|
Solar *solar.SolarData `json:"solar"`
|
||||||
Weather *weather.WeatherData `json:"weather"`
|
Weather *weather.WeatherData `json:"weather"`
|
||||||
Timestamp time.Time `json:"timestamp"`
|
Timestamp time.Time `json:"timestamp"`
|
||||||
@@ -85,6 +88,13 @@ func (dm *DeviceManager) Initialize() error {
|
|||||||
dm.config.Devices.RotatorGenius.Port,
|
dm.config.Devices.RotatorGenius.Port,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Initialize Ultrabeam
|
||||||
|
log.Printf("Initializing Ultrabeam: host=%s port=%d", dm.config.Devices.Ultrabeam.Host, dm.config.Devices.Ultrabeam.Port)
|
||||||
|
dm.ultrabeam = ultrabeam.New(
|
||||||
|
dm.config.Devices.Ultrabeam.Host,
|
||||||
|
dm.config.Devices.Ultrabeam.Port,
|
||||||
|
)
|
||||||
|
|
||||||
// Initialize Solar data client
|
// Initialize Solar data client
|
||||||
dm.solarClient = solar.New()
|
dm.solarClient = solar.New()
|
||||||
|
|
||||||
@@ -123,6 +133,15 @@ func (dm *DeviceManager) Initialize() error {
|
|||||||
}()
|
}()
|
||||||
log.Println("RotatorGenius goroutine launched")
|
log.Println("RotatorGenius goroutine launched")
|
||||||
|
|
||||||
|
log.Println("About to launch Ultrabeam goroutine...")
|
||||||
|
go func() {
|
||||||
|
log.Println("Starting Ultrabeam polling goroutine...")
|
||||||
|
if err := dm.ultrabeam.Start(); err != nil {
|
||||||
|
log.Printf("Warning: Failed to start Ultrabeam polling: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
log.Println("Ultrabeam goroutine launched")
|
||||||
|
|
||||||
log.Println("Device manager initialized")
|
log.Println("Device manager initialized")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -150,6 +169,9 @@ func (dm *DeviceManager) Stop() {
|
|||||||
if dm.rotatorGenius != nil {
|
if dm.rotatorGenius != nil {
|
||||||
dm.rotatorGenius.Close()
|
dm.rotatorGenius.Close()
|
||||||
}
|
}
|
||||||
|
if dm.ultrabeam != nil {
|
||||||
|
dm.ultrabeam.Stop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (dm *DeviceManager) monitorDevices() {
|
func (dm *DeviceManager) monitorDevices() {
|
||||||
@@ -207,6 +229,13 @@ func (dm *DeviceManager) updateStatus() {
|
|||||||
log.Printf("Rotator Genius error: %v", err)
|
log.Printf("Rotator Genius error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ultrabeam
|
||||||
|
if ubStatus, err := dm.ultrabeam.GetStatus(); err == nil {
|
||||||
|
status.Ultrabeam = ubStatus
|
||||||
|
} else {
|
||||||
|
log.Printf("Ultrabeam error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Solar Data (fetched every 15 minutes, cached)
|
// Solar Data (fetched every 15 minutes, cached)
|
||||||
if solarData, err := dm.solarClient.GetSolarData(); err == nil {
|
if solarData, err := dm.solarClient.GetSolarData(); err == nil {
|
||||||
status.Solar = solarData
|
status.Solar = solarData
|
||||||
@@ -265,3 +294,7 @@ func (dm *DeviceManager) AntennaGenius() *antennagenius.Client {
|
|||||||
func (dm *DeviceManager) RotatorGenius() *rotatorgenius.Client {
|
func (dm *DeviceManager) RotatorGenius() *rotatorgenius.Client {
|
||||||
return dm.rotatorGenius
|
return dm.rotatorGenius
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (dm *DeviceManager) Ultrabeam() *ultrabeam.Client {
|
||||||
|
return dm.ultrabeam
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ func (s *Server) SetupRoutes() *http.ServeMux {
|
|||||||
mux.HandleFunc("/api/rotator/ccw", s.handleRotatorCCW)
|
mux.HandleFunc("/api/rotator/ccw", s.handleRotatorCCW)
|
||||||
mux.HandleFunc("/api/rotator/stop", s.handleRotatorStop)
|
mux.HandleFunc("/api/rotator/stop", s.handleRotatorStop)
|
||||||
|
|
||||||
|
// Ultrabeam endpoints
|
||||||
|
mux.HandleFunc("/api/ultrabeam/frequency", s.handleUltrabeamFrequency)
|
||||||
|
mux.HandleFunc("/api/ultrabeam/retract", s.handleUltrabeamRetract)
|
||||||
|
|
||||||
// Tuner endpoints
|
// Tuner endpoints
|
||||||
mux.HandleFunc("/api/tuner/operate", s.handleTunerOperate)
|
mux.HandleFunc("/api/tuner/operate", s.handleTunerOperate)
|
||||||
mux.HandleFunc("/api/tuner/bypass", s.handleTunerBypass)
|
mux.HandleFunc("/api/tuner/bypass", s.handleTunerBypass)
|
||||||
@@ -392,6 +396,45 @@ func (s *Server) handlePowerOperate(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.sendJSON(w, map[string]string{"status": "ok"})
|
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ultrabeam handlers
|
||||||
|
func (s *Server) handleUltrabeamFrequency(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Frequency int `json:"frequency"` // KHz
|
||||||
|
Direction int `json:"direction"` // 0=normal, 1=180°, 2=bi-dir
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.deviceManager.Ultrabeam().SetFrequency(req.Frequency, req.Direction); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUltrabeamRetract(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.deviceManager.Ultrabeam().Retract(); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) sendJSON(w http.ResponseWriter, data interface{}) {
|
func (s *Server) sendJSON(w http.ResponseWriter, data interface{}) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(data)
|
json.NewEncoder(w).Encode(data)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type DevicesConfig struct {
|
|||||||
TunerGenius TunerGeniusConfig `yaml:"tuner_genius"`
|
TunerGenius TunerGeniusConfig `yaml:"tuner_genius"`
|
||||||
AntennaGenius AntennaGeniusConfig `yaml:"antenna_genius"`
|
AntennaGenius AntennaGeniusConfig `yaml:"antenna_genius"`
|
||||||
RotatorGenius RotatorGeniusConfig `yaml:"rotator_genius"`
|
RotatorGenius RotatorGeniusConfig `yaml:"rotator_genius"`
|
||||||
|
Ultrabeam UltrabeamConfig `yaml:"ultrabeam"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebSwitchConfig struct {
|
type WebSwitchConfig struct {
|
||||||
@@ -51,6 +52,11 @@ type RotatorGeniusConfig struct {
|
|||||||
Port int `yaml:"port"`
|
Port int `yaml:"port"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UltrabeamConfig struct {
|
||||||
|
Host string `yaml:"host"`
|
||||||
|
Port int `yaml:"port"`
|
||||||
|
}
|
||||||
|
|
||||||
type WeatherConfig struct {
|
type WeatherConfig struct {
|
||||||
OpenWeatherMapAPIKey string `yaml:"openweathermap_api_key"`
|
OpenWeatherMapAPIKey string `yaml:"openweathermap_api_key"`
|
||||||
LightningEnabled bool `yaml:"lightning_enabled"`
|
LightningEnabled bool `yaml:"lightning_enabled"`
|
||||||
|
|||||||
457
internal/devices/ultrabeam/ultrabeam.go
Normal file
457
internal/devices/ultrabeam/ultrabeam.go
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
package ultrabeam
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Protocol constants
|
||||||
|
const (
|
||||||
|
STX byte = 0xF5 // 245 decimal
|
||||||
|
ETX byte = 0xFA // 250 decimal
|
||||||
|
DLE byte = 0xF6 // 246 decimal
|
||||||
|
)
|
||||||
|
|
||||||
|
// Command codes
|
||||||
|
const (
|
||||||
|
CMD_STATUS byte = 1 // General status query
|
||||||
|
CMD_RETRACT byte = 2 // Retract elements
|
||||||
|
CMD_FREQ byte = 3 // Change frequency
|
||||||
|
CMD_READ_BANDS byte = 9 // Read current band adjustments
|
||||||
|
CMD_PROGRESS byte = 10 // Read progress bar
|
||||||
|
CMD_MODIFY_ELEM byte = 12 // Modify element length
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reply codes
|
||||||
|
const (
|
||||||
|
UB_OK byte = 0 // Normal execution
|
||||||
|
UB_BAD byte = 1 // Invalid command
|
||||||
|
UB_PAR byte = 2 // Bad parameters
|
||||||
|
UB_ERR byte = 3 // Error executing command
|
||||||
|
)
|
||||||
|
|
||||||
|
// Direction modes
|
||||||
|
const (
|
||||||
|
DIR_NORMAL byte = 0
|
||||||
|
DIR_180 byte = 1
|
||||||
|
DIR_BIDIR byte = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
host string
|
||||||
|
port int
|
||||||
|
conn net.Conn
|
||||||
|
connMu sync.Mutex
|
||||||
|
reader *bufio.Reader
|
||||||
|
lastStatus *Status
|
||||||
|
statusMu sync.RWMutex
|
||||||
|
stopChan chan struct{}
|
||||||
|
running bool
|
||||||
|
seqNum byte
|
||||||
|
seqMu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
type Status struct {
|
||||||
|
FirmwareMinor int `json:"firmware_minor"`
|
||||||
|
FirmwareMajor int `json:"firmware_major"`
|
||||||
|
CurrentOperation int `json:"current_operation"`
|
||||||
|
Frequency int `json:"frequency"` // KHz
|
||||||
|
Band int `json:"band"`
|
||||||
|
Direction int `json:"direction"` // 0=normal, 1=180°, 2=bi-dir
|
||||||
|
OffState bool `json:"off_state"`
|
||||||
|
MotorsMoving int `json:"motors_moving"` // Bitmask
|
||||||
|
FreqMin int `json:"freq_min"` // MHz
|
||||||
|
FreqMax int `json:"freq_max"` // MHz
|
||||||
|
ElementLengths []int `json:"element_lengths"` // mm
|
||||||
|
ProgressTotal int `json:"progress_total"` // mm
|
||||||
|
ProgressCurrent int `json:"progress_current"` // 0-60
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(host string, port int) *Client {
|
||||||
|
return &Client{
|
||||||
|
host: host,
|
||||||
|
port: port,
|
||||||
|
stopChan: make(chan struct{}),
|
||||||
|
seqNum: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Start() error {
|
||||||
|
c.running = true
|
||||||
|
go c.pollLoop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Stop() {
|
||||||
|
if !c.running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.running = false
|
||||||
|
close(c.stopChan)
|
||||||
|
|
||||||
|
c.connMu.Lock()
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
}
|
||||||
|
c.connMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) pollLoop() {
|
||||||
|
ticker := time.NewTicker(500 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
// Try to connect if not connected
|
||||||
|
c.connMu.Lock()
|
||||||
|
if c.conn == nil {
|
||||||
|
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 5*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
c.connMu.Unlock()
|
||||||
|
|
||||||
|
// Mark as disconnected
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.lastStatus = &Status{Connected: false}
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.conn = conn
|
||||||
|
c.reader = bufio.NewReader(c.conn)
|
||||||
|
log.Printf("Ultrabeam: Connected to %s:%d", c.host, c.port)
|
||||||
|
}
|
||||||
|
c.connMu.Unlock()
|
||||||
|
|
||||||
|
// Query status
|
||||||
|
status, err := c.queryStatus()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Ultrabeam: Failed to query status: %v", err)
|
||||||
|
// Close connection and retry
|
||||||
|
c.connMu.Lock()
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
c.reader = nil
|
||||||
|
}
|
||||||
|
c.connMu.Unlock()
|
||||||
|
|
||||||
|
// Mark as disconnected
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.lastStatus = &Status{Connected: false}
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as connected
|
||||||
|
status.Connected = true
|
||||||
|
|
||||||
|
// Query element lengths
|
||||||
|
lengths, err := c.queryElementLengths()
|
||||||
|
if err == nil {
|
||||||
|
status.ElementLengths = lengths
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query progress if motors moving
|
||||||
|
if status.MotorsMoving != 0 {
|
||||||
|
progress, err := c.queryProgress()
|
||||||
|
if err == nil {
|
||||||
|
status.ProgressTotal = progress[0]
|
||||||
|
status.ProgressCurrent = progress[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.lastStatus = status
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
|
||||||
|
case <-c.stopChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetStatus() (*Status, error) {
|
||||||
|
c.statusMu.RLock()
|
||||||
|
defer c.statusMu.RUnlock()
|
||||||
|
|
||||||
|
if c.lastStatus == nil {
|
||||||
|
return &Status{Connected: false}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.lastStatus, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getNextSeq returns the next sequence number
|
||||||
|
func (c *Client) getNextSeq() byte {
|
||||||
|
c.seqMu.Lock()
|
||||||
|
defer c.seqMu.Unlock()
|
||||||
|
|
||||||
|
seq := c.seqNum
|
||||||
|
c.seqNum = (c.seqNum + 1) % 128
|
||||||
|
return seq
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateChecksum calculates the checksum for a packet
|
||||||
|
func calculateChecksum(data []byte) byte {
|
||||||
|
chk := byte(0x55)
|
||||||
|
for _, b := range data {
|
||||||
|
chk ^= b
|
||||||
|
chk++
|
||||||
|
}
|
||||||
|
return chk
|
||||||
|
}
|
||||||
|
|
||||||
|
// quoteByte handles DLE escaping
|
||||||
|
func quoteByte(b byte) []byte {
|
||||||
|
if b == STX || b == ETX || b == DLE {
|
||||||
|
return []byte{DLE, b & 0x7F} // Clear MSB
|
||||||
|
}
|
||||||
|
return []byte{b}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildPacket creates a complete packet with checksum and escaping
|
||||||
|
func (c *Client) buildPacket(cmd byte, data []byte) []byte {
|
||||||
|
seq := c.getNextSeq()
|
||||||
|
|
||||||
|
// Calculate checksum on unquoted data
|
||||||
|
payload := append([]byte{seq, cmd}, data...)
|
||||||
|
chk := calculateChecksum(payload)
|
||||||
|
|
||||||
|
// Build packet with quoting
|
||||||
|
packet := []byte{STX}
|
||||||
|
|
||||||
|
// Add quoted SEQ
|
||||||
|
packet = append(packet, quoteByte(seq)...)
|
||||||
|
|
||||||
|
// Add quoted CMD
|
||||||
|
packet = append(packet, quoteByte(cmd)...)
|
||||||
|
|
||||||
|
// Add quoted data
|
||||||
|
for _, b := range data {
|
||||||
|
packet = append(packet, quoteByte(b)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add quoted checksum
|
||||||
|
packet = append(packet, quoteByte(chk)...)
|
||||||
|
|
||||||
|
// Add ETX
|
||||||
|
packet = append(packet, ETX)
|
||||||
|
|
||||||
|
return packet
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePacket parses a received packet, handling DLE unescaping
|
||||||
|
func parsePacket(data []byte) (seq byte, cmd byte, payload []byte, err error) {
|
||||||
|
if len(data) < 5 { // STX + SEQ + CMD + CHK + ETX
|
||||||
|
return 0, 0, nil, fmt.Errorf("packet too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
if data[0] != STX {
|
||||||
|
return 0, 0, nil, fmt.Errorf("missing STX")
|
||||||
|
}
|
||||||
|
|
||||||
|
if data[len(data)-1] != ETX {
|
||||||
|
return 0, 0, nil, fmt.Errorf("missing ETX")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unquote the data
|
||||||
|
var unquoted []byte
|
||||||
|
dle := false
|
||||||
|
for i := 1; i < len(data)-1; i++ {
|
||||||
|
b := data[i]
|
||||||
|
if b == DLE {
|
||||||
|
dle = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if dle {
|
||||||
|
b |= 0x80 // Set MSB
|
||||||
|
dle = false
|
||||||
|
}
|
||||||
|
unquoted = append(unquoted, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(unquoted) < 3 {
|
||||||
|
return 0, 0, nil, fmt.Errorf("unquoted packet too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
seq = unquoted[0]
|
||||||
|
cmd = unquoted[1]
|
||||||
|
chk := unquoted[len(unquoted)-1]
|
||||||
|
payload = unquoted[2 : len(unquoted)-1]
|
||||||
|
|
||||||
|
// Verify checksum
|
||||||
|
calcChk := calculateChecksum(unquoted[:len(unquoted)-1])
|
||||||
|
if calcChk != chk {
|
||||||
|
return 0, 0, nil, fmt.Errorf("checksum mismatch: got %02X, expected %02X", chk, calcChk)
|
||||||
|
}
|
||||||
|
|
||||||
|
return seq, cmd, payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendCommand sends a command and waits for reply
|
||||||
|
func (c *Client) sendCommand(cmd byte, data []byte) ([]byte, error) {
|
||||||
|
c.connMu.Lock()
|
||||||
|
defer c.connMu.Unlock()
|
||||||
|
|
||||||
|
if c.conn == nil || c.reader == nil {
|
||||||
|
return nil, fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build and send packet
|
||||||
|
packet := c.buildPacket(cmd, data)
|
||||||
|
|
||||||
|
_, err := c.conn.Write(packet)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read reply with timeout
|
||||||
|
c.conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||||
|
|
||||||
|
// Read until we get a complete packet
|
||||||
|
var buffer []byte
|
||||||
|
for {
|
||||||
|
b, err := c.reader.ReadByte()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer = append(buffer, b)
|
||||||
|
|
||||||
|
// Check if we have a complete packet
|
||||||
|
if b == ETX && len(buffer) > 0 && buffer[0] == STX {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent infinite loop
|
||||||
|
if len(buffer) > 256 {
|
||||||
|
return nil, fmt.Errorf("packet too long")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse reply
|
||||||
|
_, replyCmd, payload, err := parsePacket(buffer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse reply: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for errors
|
||||||
|
switch replyCmd {
|
||||||
|
case UB_BAD:
|
||||||
|
return nil, fmt.Errorf("invalid command")
|
||||||
|
case UB_PAR:
|
||||||
|
return nil, fmt.Errorf("bad parameters")
|
||||||
|
case UB_ERR:
|
||||||
|
return nil, fmt.Errorf("execution error")
|
||||||
|
case UB_OK:
|
||||||
|
return payload, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown reply code: %d", replyCmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryStatus queries general status (command 1)
|
||||||
|
func (c *Client) queryStatus() (*Status, error) {
|
||||||
|
reply, err := c.sendCommand(CMD_STATUS, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(reply) < 12 {
|
||||||
|
return nil, fmt.Errorf("status reply too short: %d bytes", len(reply))
|
||||||
|
}
|
||||||
|
|
||||||
|
status := &Status{
|
||||||
|
FirmwareMinor: int(reply[0]),
|
||||||
|
FirmwareMajor: int(reply[1]),
|
||||||
|
CurrentOperation: int(reply[2]),
|
||||||
|
Frequency: int(reply[3]) | (int(reply[4]) << 8),
|
||||||
|
Band: int(reply[5]),
|
||||||
|
Direction: int(reply[6] & 0x0F),
|
||||||
|
OffState: (reply[7] & 0x02) != 0,
|
||||||
|
MotorsMoving: int(reply[9]),
|
||||||
|
FreqMin: int(reply[10]),
|
||||||
|
FreqMax: int(reply[11]),
|
||||||
|
}
|
||||||
|
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryElementLengths queries element lengths (command 9)
|
||||||
|
func (c *Client) queryElementLengths() ([]int, error) {
|
||||||
|
reply, err := c.sendCommand(CMD_READ_BANDS, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(reply) < 12 {
|
||||||
|
return nil, fmt.Errorf("element lengths reply too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
lengths := make([]int, 6)
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
lo := int(reply[i*2])
|
||||||
|
hi := int(reply[i*2+1])
|
||||||
|
lengths[i] = lo | (hi << 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lengths, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryProgress queries motor progress (command 10)
|
||||||
|
func (c *Client) queryProgress() ([]int, error) {
|
||||||
|
reply, err := c.sendCommand(CMD_PROGRESS, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(reply) < 4 {
|
||||||
|
return nil, fmt.Errorf("progress reply too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
total := int(reply[0]) | (int(reply[1]) << 8)
|
||||||
|
current := int(reply[2]) | (int(reply[3]) << 8)
|
||||||
|
|
||||||
|
return []int{total, current}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFrequency changes frequency and optional direction (command 3)
|
||||||
|
func (c *Client) SetFrequency(freqKhz int, direction int) error {
|
||||||
|
data := []byte{
|
||||||
|
byte(freqKhz & 0xFF),
|
||||||
|
byte((freqKhz >> 8) & 0xFF),
|
||||||
|
byte(direction),
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := c.sendCommand(CMD_FREQ, data)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retract retracts all elements (command 2)
|
||||||
|
func (c *Client) Retract() error {
|
||||||
|
_, err := c.sendCommand(CMD_RETRACT, nil)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModifyElement modifies element length (command 12)
|
||||||
|
func (c *Client) ModifyElement(elementNum int, lengthMm int) error {
|
||||||
|
if elementNum < 0 || elementNum > 5 {
|
||||||
|
return fmt.Errorf("invalid element number: %d", elementNum)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := []byte{
|
||||||
|
byte(elementNum),
|
||||||
|
0, // Reserved
|
||||||
|
byte(lengthMm & 0xFF),
|
||||||
|
byte((lengthMm >> 8) & 0xFF),
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := c.sendCommand(CMD_MODIFY_ELEM, data)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
import TunerGenius from './components/TunerGenius.svelte';
|
import TunerGenius from './components/TunerGenius.svelte';
|
||||||
import AntennaGenius from './components/AntennaGenius.svelte';
|
import AntennaGenius from './components/AntennaGenius.svelte';
|
||||||
import RotatorGenius from './components/RotatorGenius.svelte';
|
import RotatorGenius from './components/RotatorGenius.svelte';
|
||||||
|
import Ultrabeam from './components/Ultrabeam.svelte';
|
||||||
|
|
||||||
let status = null;
|
let status = null;
|
||||||
let isConnected = false;
|
let isConnected = false;
|
||||||
@@ -118,6 +119,10 @@
|
|||||||
<AntennaGenius status={status?.antenna_genius} />
|
<AntennaGenius status={status?.antenna_genius} />
|
||||||
<RotatorGenius status={status?.rotator_genius} />
|
<RotatorGenius status={status?.rotator_genius} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<Ultrabeam status={status?.ultrabeam} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
547
web/src/components/Ultrabeam.svelte
Normal file
547
web/src/components/Ultrabeam.svelte
Normal file
@@ -0,0 +1,547 @@
|
|||||||
|
<script>
|
||||||
|
import { api } from '../lib/api.js';
|
||||||
|
|
||||||
|
export let status;
|
||||||
|
|
||||||
|
$: connected = status?.connected || false;
|
||||||
|
$: frequency = status?.frequency || 0;
|
||||||
|
$: band = status?.band || 0;
|
||||||
|
$: direction = status?.direction || 0;
|
||||||
|
$: motorsMoving = status?.motors_moving || 0;
|
||||||
|
$: progressTotal = status?.progress_total || 0;
|
||||||
|
$: progressCurrent = status?.progress_current || 0;
|
||||||
|
$: elementLengths = status?.element_lengths || [];
|
||||||
|
$: firmwareVersion = status ? `${status.firmware_major}.${status.firmware_minor}` : '0.0';
|
||||||
|
|
||||||
|
// Band names mapping
|
||||||
|
const bandNames = [
|
||||||
|
'160M', '80M', '60M', '40M', '30M', '20M',
|
||||||
|
'17M', '15M', '12M', '10M', '6M'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Direction names
|
||||||
|
const directionNames = ['Normal', '180°', 'Bi-Dir'];
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
let targetFreq = 0;
|
||||||
|
let targetDirection = 0;
|
||||||
|
|
||||||
|
// Element calibration state
|
||||||
|
let calibrationMode = false;
|
||||||
|
let selectedElement = 0;
|
||||||
|
let elementAdjustment = 0;
|
||||||
|
|
||||||
|
async function setFrequency() {
|
||||||
|
if (targetFreq < 1800 || targetFreq > 30000) {
|
||||||
|
alert('Frequency must be between 1.8 MHz and 30 MHz');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.ultrabeam.setFrequency(targetFreq, targetDirection);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to set frequency:', err);
|
||||||
|
alert('Failed to set frequency');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retract() {
|
||||||
|
if (!confirm('Retract all antenna elements?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.ultrabeam.retract();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to retract:', err);
|
||||||
|
alert('Failed to retract');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adjustElement() {
|
||||||
|
try {
|
||||||
|
const newLength = elementLengths[selectedElement] + elementAdjustment;
|
||||||
|
// TODO: Add API call when backend supports it
|
||||||
|
alert(`Would adjust element ${selectedElement} by ${elementAdjustment}mm to ${newLength}mm`);
|
||||||
|
elementAdjustment = 0;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to adjust element:', err);
|
||||||
|
alert('Failed to adjust element');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate progress percentage
|
||||||
|
$: progressPercent = progressTotal > 0 ? (progressCurrent / 60) * 100 : 0;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2>Ultrabeam VL2.3</h2>
|
||||||
|
<span class="status-dot" class:disconnected={!connected}></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metrics">
|
||||||
|
<!-- Current Status -->
|
||||||
|
<div class="status-grid">
|
||||||
|
<div class="status-item">
|
||||||
|
<div class="status-label">Frequency</div>
|
||||||
|
<div class="status-value freq">{(frequency / 1000).toFixed(3)} MHz</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-item">
|
||||||
|
<div class="status-label">Band</div>
|
||||||
|
<div class="status-value band">{bandNames[band] || 'Unknown'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-item">
|
||||||
|
<div class="status-label">Direction</div>
|
||||||
|
<div class="status-value direction">{directionNames[direction]}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-item">
|
||||||
|
<div class="status-label">Firmware</div>
|
||||||
|
<div class="status-value fw">v{firmwareVersion}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Frequency Control -->
|
||||||
|
<div class="control-section">
|
||||||
|
<h3>Frequency Control</h3>
|
||||||
|
<div class="freq-control">
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="target-freq">Target Frequency (KHz)</label>
|
||||||
|
<input
|
||||||
|
id="target-freq"
|
||||||
|
type="number"
|
||||||
|
bind:value={targetFreq}
|
||||||
|
min="1800"
|
||||||
|
max="30000"
|
||||||
|
step="1"
|
||||||
|
placeholder="e.g. 14200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="target-dir">Direction</label>
|
||||||
|
<select id="target-dir" bind:value={targetDirection}>
|
||||||
|
<option value={0}>Normal</option>
|
||||||
|
<option value={1}>180°</option>
|
||||||
|
<option value={2}>Bi-Directional</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn-primary" on:click={setFrequency}>
|
||||||
|
<span class="icon">📡</span>
|
||||||
|
Set Frequency
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Motor Progress -->
|
||||||
|
{#if motorsMoving > 0}
|
||||||
|
<div class="progress-section">
|
||||||
|
<h3>Motors Moving...</h3>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" style="width: {progressPercent}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-text">{progressCurrent} / 60 ({progressPercent.toFixed(0)}%)</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Element Lengths Display -->
|
||||||
|
<div class="elements-section">
|
||||||
|
<h3>Element Lengths (mm)</h3>
|
||||||
|
<div class="elements-grid">
|
||||||
|
{#each elementLengths as length, i}
|
||||||
|
{#if length > 0}
|
||||||
|
<div class="element-item">
|
||||||
|
<div class="element-label">Element {i + 1}</div>
|
||||||
|
<div class="element-value">{length} mm</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Calibration Mode -->
|
||||||
|
<div class="calibration-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3>Calibration</h3>
|
||||||
|
<button
|
||||||
|
class="btn-toggle"
|
||||||
|
class:active={calibrationMode}
|
||||||
|
on:click={() => calibrationMode = !calibrationMode}
|
||||||
|
>
|
||||||
|
{calibrationMode ? 'Hide' : 'Show'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if calibrationMode}
|
||||||
|
<div class="calibration-controls">
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="element-select">Element</label>
|
||||||
|
<select id="element-select" bind:value={selectedElement}>
|
||||||
|
{#each elementLengths as length, i}
|
||||||
|
{#if length > 0}
|
||||||
|
<option value={i}>Element {i + 1} ({length}mm)</option>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="adjustment">Adjustment (mm)</label>
|
||||||
|
<input
|
||||||
|
id="adjustment"
|
||||||
|
type="number"
|
||||||
|
bind:value={elementAdjustment}
|
||||||
|
step="1"
|
||||||
|
placeholder="±10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn-caution" on:click={adjustElement}>
|
||||||
|
<span class="icon">⚙️</span>
|
||||||
|
Apply Adjustment
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="warning-text">
|
||||||
|
⚠️ Calibration changes are saved after 12 seconds. Do not turn off during this time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn-danger" on:click={retract}>
|
||||||
|
<span class="icon">↓</span>
|
||||||
|
Retract Elements
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card {
|
||||||
|
background: linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(15, 23, 42, 0.98) 100%);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
border: 1px solid rgba(79, 195, 247, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 2px solid rgba(79, 195, 247, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: linear-gradient(135deg, #4fc3f7 0%, #03a9f4 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
text-shadow: 0 0 20px rgba(79, 195, 247, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #4fc3f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #4caf50;
|
||||||
|
box-shadow: 0 0 12px rgba(76, 175, 80, 0.8);
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.disconnected {
|
||||||
|
background: #666;
|
||||||
|
box-shadow: none;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Grid */
|
||||||
|
.status-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-item {
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(79, 195, 247, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-value {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4fc3f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-value.freq {
|
||||||
|
color: #66bb6a;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-value.band {
|
||||||
|
color: #ffa726;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-value.direction {
|
||||||
|
color: #ab47bc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Control Section */
|
||||||
|
.control-section {
|
||||||
|
background: rgba(15, 23, 42, 0.4);
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(79, 195, 247, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.freq-control {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="number"],
|
||||||
|
select {
|
||||||
|
background: rgba(15, 23, 42, 0.8);
|
||||||
|
border: 1px solid rgba(79, 195, 247, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 16px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="number"]:focus,
|
||||||
|
select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #4fc3f7;
|
||||||
|
box-shadow: 0 0 12px rgba(79, 195, 247, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
button {
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #4fc3f7 0%, #03a9f4 100%);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 4px 16px rgba(79, 195, 247, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(79, 195, 247, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: linear-gradient(135deg, #f44336 0%, #d32f2f 100%);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 4px 16px rgba(244, 67, 54, 0.4);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(244, 67, 54, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-caution {
|
||||||
|
background: linear-gradient(135deg, #ffa726 0%, #fb8c00 100%);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 4px 16px rgba(255, 167, 38, 0.4);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-caution:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(255, 167, 38, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-toggle {
|
||||||
|
background: rgba(79, 195, 247, 0.1);
|
||||||
|
color: #4fc3f7;
|
||||||
|
border: 1px solid rgba(79, 195, 247, 0.3);
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-toggle.active {
|
||||||
|
background: rgba(79, 195, 247, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress */
|
||||||
|
.progress-section {
|
||||||
|
background: rgba(15, 23, 42, 0.4);
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(255, 193, 7, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 24px;
|
||||||
|
background: rgba(15, 23, 42, 0.8);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 12px 0;
|
||||||
|
border: 1px solid rgba(79, 195, 247, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #4fc3f7 0%, #66bb6a 100%);
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
box-shadow: 0 0 12px rgba(79, 195, 247, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
text-align: center;
|
||||||
|
color: #4fc3f7;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Elements */
|
||||||
|
.elements-section {
|
||||||
|
background: rgba(15, 23, 42, 0.4);
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(79, 195, 247, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.elements-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.element-item {
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(79, 195, 247, 0.2);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.element-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.element-value {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #66bb6a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Calibration */
|
||||||
|
.calibration-section {
|
||||||
|
background: rgba(255, 152, 0, 0.05);
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(255, 152, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calibration-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-text {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(255, 152, 0, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 3px solid #ffa726;
|
||||||
|
color: #ffa726;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.freq-control {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.elements-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -89,4 +89,13 @@ export const api = {
|
|||||||
rotateCCW: () => request('/rotator/ccw', { method: 'POST' }),
|
rotateCCW: () => request('/rotator/ccw', { method: 'POST' }),
|
||||||
stop: () => request('/rotator/stop', { method: 'POST' }),
|
stop: () => request('/rotator/stop', { method: 'POST' }),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Ultrabeam
|
||||||
|
ultrabeam: {
|
||||||
|
setFrequency: (frequency, direction) => request('/ultrabeam/frequency', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ frequency, direction }),
|
||||||
|
}),
|
||||||
|
retract: () => request('/ultrabeam/retract', { method: 'POST' }),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user