From 1ee0afa088a168fb9eb5f9c595fcd3a36bb66286 Mon Sep 17 00:00:00 2001 From: rouggy Date: Thu, 8 Jan 2026 21:58:12 +0100 Subject: [PATCH] first --- .gitignore | 43 ++++ configs/config.example.yaml | 33 +++ go.mod | 2 + go.sum | 3 + internal/config/config.go | 78 ++++++ .../devices/rotatorgenius/rotatorgenius.go | 232 ++++++++++++++++++ internal/devices/tunergenius/tunergenius.go | 202 +++++++++++++++ internal/devices/webswitch/webswitch.go | 94 +++++++ pkg/protocol/types.go | 38 +++ 9 files changed, 725 insertions(+) create mode 100644 .gitignore create mode 100644 configs/config.example.yaml create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/devices/rotatorgenius/rotatorgenius.go create mode 100644 internal/devices/tunergenius/tunergenius.go create mode 100644 internal/devices/webswitch/webswitch.go create mode 100644 pkg/protocol/types.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ad6177e --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# Binaries +shackmaster-server +shackmaster-client +*.exe +*.dll +*.so +*.dylib + +# Test binary +*.test + +# Output of the go coverage tool +*.out + +# Dependency directories +vendor/ + +# Go workspace file +go.work + +# Config files with secrets +configs/config.yaml + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Frontend +web/node_modules/ +web/dist/ +web/build/ +web/.svelte-kit/ +web/package-lock.json + +# Logs +*.log \ No newline at end of file diff --git a/configs/config.example.yaml b/configs/config.example.yaml new file mode 100644 index 0000000..209d694 --- /dev/null +++ b/configs/config.example.yaml @@ -0,0 +1,33 @@ +server: + host: "0.0.0.0" + port: 8081 + +devices: + webswitch: + host: "10.10.10.119" + + power_genius: + host: "10.10.10.128" + port: 9008 + + tuner_genius: + host: "10.10.10.129" + port: 9010 + id_number: 1 # Default ID for commands + + antenna_genius: + host: "10.10.10.130" + port: 9007 + + rotator_genius: + host: "10.10.10.121" + port: 9006 + +weather: + openweathermap_api_key: "YOUR_API_KEY_HERE" + lightning_enabled: true + +location: + latitude: 46.2833 + longitude: 6.2333 + callsign: "F4BPO" \ No newline at end of file diff --git a/go.mod b/go.mod index 3a1af70..037ed01 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module git.rouggy.com/rouggy/ShackMaster go 1.24.3 + +require gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4bc0337 --- /dev/null +++ b/go.sum @@ -0,0 +1,3 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..a3a6950 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,78 @@ +package config + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +type Config struct { + Server ServerConfig `yaml:"server"` + Devices DevicesConfig `yaml:"devices"` + Weather WeatherConfig `yaml:"weather"` + Location LocationConfig `yaml:"location"` +} + +type ServerConfig struct { + Host string `yaml:"host"` + Port int `yaml:"port"` +} + +type DevicesConfig struct { + WebSwitch WebSwitchConfig `yaml:"webswitch"` + PowerGenius PowerGeniusConfig `yaml:"power_genius"` + TunerGenius TunerGeniusConfig `yaml:"tuner_genius"` + AntennaGenius AntennaGeniusConfig `yaml:"antenna_genius"` + RotatorGenius RotatorGeniusConfig `yaml:"rotator_genius"` +} + +type WebSwitchConfig struct { + Host string `yaml:"host"` +} + +type PowerGeniusConfig struct { + Host string `yaml:"host"` + Port int `yaml:"port"` +} + +type TunerGeniusConfig struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + IDNumber int `yaml:"id_number"` +} + +type AntennaGeniusConfig struct { + Host string `yaml:"host"` + Port int `yaml:"port"` +} + +type RotatorGeniusConfig struct { + Host string `yaml:"host"` + Port int `yaml:"port"` +} + +type WeatherConfig struct { + OpenWeatherMapAPIKey string `yaml:"openweathermap_api_key"` + LightningEnabled bool `yaml:"lightning_enabled"` +} + +type LocationConfig struct { + Latitude float64 `yaml:"latitude"` + Longitude float64 `yaml:"longitude"` + Callsign string `yaml:"callsign"` +} + +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse config file: %w", err) + } + + return &cfg, nil +} diff --git a/internal/devices/rotatorgenius/rotatorgenius.go b/internal/devices/rotatorgenius/rotatorgenius.go new file mode 100644 index 0000000..c704b15 --- /dev/null +++ b/internal/devices/rotatorgenius/rotatorgenius.go @@ -0,0 +1,232 @@ +package rotatorgenius + +import ( + "bufio" + "fmt" + "net" + "strconv" + "strings" + "time" +) + +type Client struct { + host string + port int + conn net.Conn +} + +type Status struct { + Rotator1 RotatorData `json:"rotator1"` + Rotator2 RotatorData `json:"rotator2"` + Panic bool `json:"panic"` +} + +type RotatorData struct { + CurrentAzimuth int `json:"current_azimuth"` + LimitCW int `json:"limit_cw"` + LimitCCW int `json:"limit_ccw"` + Configuration string `json:"configuration"` // "A" for azimuth, "E" for elevation + Moving int `json:"moving"` // 0=stopped, 1=CW, 2=CCW + Offset int `json:"offset"` + TargetAzimuth int `json:"target_azimuth"` + StartAzimuth int `json:"start_azimuth"` + OutsideLimit bool `json:"outside_limit"` + Name string `json:"name"` + Connected bool `json:"connected"` +} + +func New(host string, port int) *Client { + return &Client{ + host: host, + port: port, + } +} + +func (c *Client) Connect() error { + conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 5*time.Second) + if err != nil { + return fmt.Errorf("failed to connect: %w", err) + } + c.conn = conn + return nil +} + +func (c *Client) Close() error { + if c.conn != nil { + return c.conn.Close() + } + return nil +} + +func (c *Client) sendCommand(cmd string) (string, error) { + if c.conn == nil { + if err := c.Connect(); err != nil { + return "", err + } + } + + // Send command + _, err := c.conn.Write([]byte(cmd)) + if err != nil { + c.conn = nil + return "", fmt.Errorf("failed to send command: %w", err) + } + + // Read response + reader := bufio.NewReader(c.conn) + response, err := reader.ReadString('\n') + if err != nil { + c.conn = nil + return "", fmt.Errorf("failed to read response: %w", err) + } + + return strings.TrimSpace(response), nil +} + +func (c *Client) GetStatus() (*Status, error) { + resp, err := c.sendCommand("|h") + if err != nil { + return nil, err + } + + return parseStatusResponse(resp) +} + +func parseStatusResponse(resp string) (*Status, error) { + if len(resp) < 80 { + return nil, fmt.Errorf("response too short: %d bytes", len(resp)) + } + + status := &Status{} + + // Parse panic flag + status.Panic = resp[3] != 0x00 + + // Parse Rotator 1 (positions 4-38) + status.Rotator1 = parseRotatorData(resp[4:38]) + + // Parse Rotator 2 (positions 38-72) + if len(resp) >= 72 { + status.Rotator2 = parseRotatorData(resp[38:72]) + } + + return status, nil +} + +func parseRotatorData(data string) RotatorData { + rd := RotatorData{} + + // Current azimuth (3 bytes) + if azStr := strings.TrimSpace(data[0:3]); azStr != "999" { + rd.CurrentAzimuth, _ = strconv.Atoi(azStr) + rd.Connected = true + } else { + rd.CurrentAzimuth = 999 + rd.Connected = false + } + + // Limits + rd.LimitCW, _ = strconv.Atoi(strings.TrimSpace(data[3:6])) + rd.LimitCCW, _ = strconv.Atoi(strings.TrimSpace(data[6:9])) + + // Configuration + rd.Configuration = string(data[9]) + + // Moving state + rd.Moving, _ = strconv.Atoi(string(data[10])) + + // Offset + rd.Offset, _ = strconv.Atoi(strings.TrimSpace(data[11:15])) + + // Target azimuth + if targetStr := strings.TrimSpace(data[15:18]); targetStr != "999" { + rd.TargetAzimuth, _ = strconv.Atoi(targetStr) + } else { + rd.TargetAzimuth = 999 + } + + // Start azimuth + if startStr := strings.TrimSpace(data[18:21]); startStr != "999" { + rd.StartAzimuth, _ = strconv.Atoi(startStr) + } else { + rd.StartAzimuth = 999 + } + + // Limit flag + rd.OutsideLimit = data[21] == '1' + + // Name + rd.Name = strings.TrimSpace(data[22:34]) + + return rd +} + +func (c *Client) MoveToAzimuth(rotator int, azimuth int) error { + if rotator < 1 || rotator > 2 { + return fmt.Errorf("rotator must be 1 or 2") + } + if azimuth < 0 || azimuth > 360 { + return fmt.Errorf("azimuth must be between 0 and 360") + } + + cmd := fmt.Sprintf("|A%d%03d", rotator, azimuth) + resp, err := c.sendCommand(cmd) + if err != nil { + return err + } + + if !strings.HasSuffix(resp, "K") { + return fmt.Errorf("command failed: %s", resp) + } + + return nil +} + +func (c *Client) RotateCW(rotator int) error { + if rotator < 1 || rotator > 2 { + return fmt.Errorf("rotator must be 1 or 2") + } + + cmd := fmt.Sprintf("|P%d", rotator) + resp, err := c.sendCommand(cmd) + if err != nil { + return err + } + + if !strings.HasSuffix(resp, "K") { + return fmt.Errorf("command failed: %s", resp) + } + + return nil +} + +func (c *Client) RotateCCW(rotator int) error { + if rotator < 1 || rotator > 2 { + return fmt.Errorf("rotator must be 1 or 2") + } + + cmd := fmt.Sprintf("|M%d", rotator) + resp, err := c.sendCommand(cmd) + if err != nil { + return err + } + + if !strings.HasSuffix(resp, "K") { + return fmt.Errorf("command failed: %s", resp) + } + + return nil +} + +func (c *Client) Stop() error { + resp, err := c.sendCommand("|S") + if err != nil { + return err + } + + if !strings.HasSuffix(resp, "K") { + return fmt.Errorf("command failed: %s", resp) + } + + return nil +} diff --git a/internal/devices/tunergenius/tunergenius.go b/internal/devices/tunergenius/tunergenius.go new file mode 100644 index 0000000..c3bf5e1 --- /dev/null +++ b/internal/devices/tunergenius/tunergenius.go @@ -0,0 +1,202 @@ +package tunergenius + +import ( + "bufio" + "fmt" + "net" + "strings" + "time" +) + +type Client struct { + host string + port int + idNumber int + conn net.Conn +} + +type Status struct { + Operate bool `json:"operate"` // true = OPERATE, false = STANDBY + Bypass bool `json:"bypass"` // Bypass mode + ActiveAntenna int `json:"active_antenna"` // 0=ANT1, 1=ANT2, 2=ANT3 + TuningStatus string `json:"tuning_status"` + FrequencyA float64 `json:"frequency_a"` + FrequencyB float64 `json:"frequency_b"` + C1 int `json:"c1"` + L int `json:"l"` + C2 int `json:"c2"` + SWR float64 `json:"swr"` + Power float64 `json:"power"` + Temperature float64 `json:"temperature"` + Connected bool `json:"connected"` +} + +func New(host string, port int, idNumber int) *Client { + return &Client{ + host: host, + port: port, + idNumber: idNumber, + } +} + +func (c *Client) Connect() error { + conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 5*time.Second) + if err != nil { + return fmt.Errorf("failed to connect: %w", err) + } + c.conn = conn + return nil +} + +func (c *Client) Close() error { + if c.conn != nil { + return c.conn.Close() + } + return nil +} + +func (c *Client) sendCommand(cmd string) (string, error) { + if c.conn == nil { + if err := c.Connect(); err != nil { + return "", err + } + } + + // Format command with ID + fullCmd := fmt.Sprintf("C%d|%s\n", c.idNumber, cmd) + + // Send command + _, err := c.conn.Write([]byte(fullCmd)) + if err != nil { + c.conn = nil + return "", fmt.Errorf("failed to send command: %w", err) + } + + // Read response + reader := bufio.NewReader(c.conn) + response, err := reader.ReadString('\n') + if err != nil { + c.conn = nil + return "", fmt.Errorf("failed to read response: %w", err) + } + + return strings.TrimSpace(response), nil +} + +func (c *Client) GetStatus() (*Status, error) { + resp, err := c.sendCommand("status") + if err != nil { + return nil, err + } + + // Parse the response - format will depend on actual device response + // This is a placeholder that should be updated based on real response format + status := &Status{ + Connected: true, + } + + // TODO: Parse actual status response from device + // The response format needs to be determined from real device testing + // For now, we just check if we got a response + _ = resp // Temporary: will be used when we parse the actual response format + + return status, nil +} + +func (c *Client) SetOperate(operate bool) error { + var state int + if operate { + state = 1 + } + + cmd := fmt.Sprintf("operate set=%d", state) + resp, err := c.sendCommand(cmd) + if err != nil { + return err + } + + // Check if command was successful + if resp == "" { + return fmt.Errorf("empty response from device") + } + + return nil +} + +func (c *Client) SetBypass(bypass bool) error { + var state int + if bypass { + state = 1 + } + + cmd := fmt.Sprintf("bypass set=%d", state) + resp, err := c.sendCommand(cmd) + if err != nil { + return err + } + + // Check if command was successful + if resp == "" { + return fmt.Errorf("empty response from device") + } + + return nil +} + +func (c *Client) ActivateAntenna(antenna int) error { + if antenna < 0 || antenna > 2 { + return fmt.Errorf("antenna must be 0 (ANT1), 1 (ANT2), or 2 (ANT3)") + } + + cmd := fmt.Sprintf("activate ant=%d", antenna) + resp, err := c.sendCommand(cmd) + if err != nil { + return err + } + + // Check if command was successful + if resp == "" { + return fmt.Errorf("empty response from device") + } + + return nil +} + +func (c *Client) AutoTune() error { + resp, err := c.sendCommand("autotune") + if err != nil { + return err + } + + // Check if command was successful + if resp == "" { + return fmt.Errorf("empty response from device") + } + + return nil +} + +// TuneRelay adjusts tuning parameters manually +// relay: 0=C1, 1=L, 2=C2 +// move: -1 to decrease, 1 to increase +func (c *Client) TuneRelay(relay int, move int) error { + if relay < 0 || relay > 2 { + return fmt.Errorf("relay must be 0 (C1), 1 (L), or 2 (C2)") + } + if move != -1 && move != 1 { + return fmt.Errorf("move must be -1 or 1") + } + + cmd := fmt.Sprintf("tune relay=%d move=%d", relay, move) + resp, err := c.sendCommand(cmd) + if err != nil { + return err + } + + // Check if command was successful + if resp == "" { + return fmt.Errorf("empty response from device") + } + + return nil +} diff --git a/internal/devices/webswitch/webswitch.go b/internal/devices/webswitch/webswitch.go new file mode 100644 index 0000000..5b6d1e0 --- /dev/null +++ b/internal/devices/webswitch/webswitch.go @@ -0,0 +1,94 @@ +package webswitch + +import ( + "fmt" + "io" + "net/http" + "time" +) + +type Client struct { + host string + httpClient *http.Client +} + +type Status struct { + Relays []RelayState `json:"relays"` +} + +type RelayState struct { + Number int `json:"number"` + State bool `json:"state"` +} + +func New(host string) *Client { + return &Client{ + host: host, + httpClient: &http.Client{ + Timeout: 5 * time.Second, + }, + } +} + +func (c *Client) SetRelay(relay int, state bool) error { + if relay < 1 || relay > 5 { + return fmt.Errorf("relay number must be between 1 and 5") + } + + action := "off" + if state { + action = "on" + } + + url := fmt.Sprintf("http://%s/relaycontrol/%s/%d", c.host, action, relay) + + resp, err := c.httpClient.Get(url) + if err != nil { + return fmt.Errorf("failed to control relay: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(body)) + } + + return nil +} + +func (c *Client) TurnOn(relay int) error { + return c.SetRelay(relay, true) +} + +func (c *Client) TurnOff(relay int) error { + return c.SetRelay(relay, false) +} + +func (c *Client) AllOn() error { + for i := 1; i <= 5; i++ { + if err := c.TurnOn(i); err != nil { + return fmt.Errorf("failed to turn on relay %d: %w", i, err) + } + } + return nil +} + +func (c *Client) AllOff() error { + for i := 1; i <= 5; i++ { + if err := c.TurnOff(i); err != nil { + return fmt.Errorf("failed to turn off relay %d: %w", i, err) + } + } + return nil +} + +// Ping checks if the device is reachable +func (c *Client) Ping() error { + url := fmt.Sprintf("http://%s/", c.host) + resp, err := c.httpClient.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + return nil +} diff --git a/pkg/protocol/types.go b/pkg/protocol/types.go new file mode 100644 index 0000000..b9d5e9c --- /dev/null +++ b/pkg/protocol/types.go @@ -0,0 +1,38 @@ +package protocol + +import "time" + +// WebSocketMessage represents messages sent between server and client +type WebSocketMessage struct { + Type string `json:"type"` + Device string `json:"device,omitempty"` + Data interface{} `json:"data,omitempty"` + Error string `json:"error,omitempty"` + Timestamp time.Time `json:"timestamp"` +} + +// DeviceStatus represents the status of any device +type DeviceStatus struct { + Connected bool `json:"connected"` + LastSeen time.Time `json:"last_seen"` + Error string `json:"error,omitempty"` +} + +// Message types +const ( + MsgTypeStatus = "status" + MsgTypeCommand = "command" + MsgTypeUpdate = "update" + MsgTypeError = "error" + MsgTypeWeather = "weather" + MsgTypeLightning = "lightning" +) + +// Device names +const ( + DeviceWebSwitch = "webswitch" + DevicePowerGenius = "power_genius" + DeviceTunerGenius = "tuner_genius" + DeviceAntennaGenius = "antenna_genius" + DeviceRotatorGenius = "rotator_genius" +)