From b1328b8b9268a48580f5f27a8897797eb6a02b48 Mon Sep 17 00:00:00 2001 From: Cameron Date: Fri, 29 May 2026 12:42:54 +1200 Subject: [PATCH 01/10] Add command to copy files --- internal/tc2-hat-trap-cli/main.go | 99 ++++++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 1 deletion(-) diff --git a/internal/tc2-hat-trap-cli/main.go b/internal/tc2-hat-trap-cli/main.go index 7bddc2f..998b422 100644 --- a/internal/tc2-hat-trap-cli/main.go +++ b/internal/tc2-hat-trap-cli/main.go @@ -1,10 +1,13 @@ package trapcli import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" "os" + "path/filepath" "strings" "time" @@ -28,6 +31,7 @@ type Args struct { Write *Write `arg:"subcommand:write" help:"Write to a variable."` Listen *Listen `arg:"subcommand:listen" help:"Continuously listen for messages from the RP2040."` Message *CMDMessage `arg:"subcommand:msg" help:"Send a message to the RP2040."` + CopyFile *CopyFile `arg:"subcommand:copy-file" help:"Copy a file to the RP2040."` BaudRate int `arg:"--baud-rate" help:"Baud rate for UART communication."` goconfig.ConfigArgs logging.LogArgs @@ -39,6 +43,11 @@ type CMDMessage struct { Payload string `arg:"--payload,required" help:"The payload of the message to send."` } +type CopyFile struct { + Source string `arg:"--source,required" help:"The source file to copy."` + Dest string `arg:"--dest,required" help:"The destination file to copy to."` +} + type Command struct { Command string `arg:"--command,required" help:"The command to run."` } @@ -150,6 +159,94 @@ func Run(inputArgs []string, ver string) error { message := comms.Message{ID: args.Message.ID, Type: args.Message.Type, Payload: args.Message.Payload} return respond(sendMessage(message, port)) + case args.CopyFile != nil: + localFile := args.CopyFile.Source + destFile := args.CopyFile.Dest + + // Check that local file exists. + localData, err := os.ReadFile(localFile) + if err != nil { + return fmt.Errorf("failed to read local file %s: %v", localFile, err) + } + + // Calculate the hash of the file, just he first 10 characters. + h := sha256.Sum256(localData) + localHash := hex.EncodeToString(h[:])[:10] + destBase := filepath.Base(destFile) + tmpBase := destBase + ".tmp" + + // Check if the files already matches + lsResp, err := sendMessage(comms.Message{Type: "LS", Payload: destBase + "," + tmpBase}, port) + if err != nil { + return fmt.Errorf("failed to list files: %v", err) + } + var fileHashes map[string]string + if err := json.Unmarshal([]byte(lsResp.Payload), &fileHashes); err != nil { + return fmt.Errorf("failed to parse LS response: %v", err) + } + if fileHashes[destBase] == localHash { + log.Printf("File %s already up to date", destFile) + return nil + } + + // Delete the temp file if it already exists + if _, ok := fileHashes[tmpBase]; ok { + if err := respond(sendMessage(comms.Message{Type: "DELETE", Payload: tmpBase}, port)); err != nil { + return fmt.Errorf("failed to delete temp file: %v", err) + } + } + + // Split the file into lines. Removing the trailing newline as that will be added by the RP2040 + lines := strings.Split(strings.TrimSuffix(string(localData), "\n"), "\n") + if len(lines) == 1 && lines[0] == "" { + lines = nil + } + + for _, line := range lines { + lineSend := []string{line} + chunk, err := json.Marshal(lineSend) + if err != nil { + return fmt.Errorf("failed to marshal chunk: %v", err) + } + if err := respond(sendMessage(comms.Message{Type: "WRITE", Payload: tmpBase + "," + string(chunk)}, port)); err != nil { + return fmt.Errorf("failed to write chunk at line %s: %v", line, err) + } + } + + // Verify temp file. + lsResp2, err := sendMessage(comms.Message{Type: "LS", Payload: tmpBase}, port) + if err != nil { + return fmt.Errorf("failed to verify file: %v", err) + } + var fileHashes2 map[string]string + if err := json.Unmarshal([]byte(lsResp2.Payload), &fileHashes2); err != nil { + return fmt.Errorf("failed to parse verify LS response: %v", err) + } + if fileHashes2[tmpBase] != localHash { + return fmt.Errorf("file verification failed: hash mismatch") + } + + // Move the temp file to the destination + if err := respond(sendMessage(comms.Message{Type: "MV", Payload: tmpBase + "," + destBase}, port)); err != nil { + return fmt.Errorf("failed to move file: %v", err) + } + + // Verify the final file + lsResp3, err := sendMessage(comms.Message{Type: "LS", Payload: destBase}, port) + if err != nil { + return fmt.Errorf("failed to verify file: %v", err) + } + var fileHashes3 map[string]string + if err := json.Unmarshal([]byte(lsResp3.Payload), &fileHashes3); err != nil { + return fmt.Errorf("failed to parse verify LS response: %v", err) + } + if fileHashes3[destBase] != localHash { + return fmt.Errorf("file verification failed: hash mismatch. Got %s, expected %s", fileHashes3[tmpBase], localHash) + } + + log.Printf("File %s copied successfully", destFile) + return nil + default: return fmt.Errorf("no subcommand given") } @@ -162,6 +259,6 @@ func respond(response *comms.Message, err error) error { if response.Type == "NACK" { return fmt.Errorf("NACK response: %s", response.Payload) } - fmt.Printf("type=%s payload=%s\n", response.Type, response.Payload) + log.Infof("Response: type=%s, payload=%s", response.Type, response.Payload) return nil } From 98b88d45dc755ef9deba25074fd5c68e1d24cf2e Mon Sep 17 00:00:00 2001 From: Cameron Date: Fri, 29 May 2026 14:39:05 +1200 Subject: [PATCH 02/10] Add compression for file transfer --- internal/tc2-hat-trap-cli/main.go | 48 +++++++++++++++---------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/internal/tc2-hat-trap-cli/main.go b/internal/tc2-hat-trap-cli/main.go index 998b422..7712801 100644 --- a/internal/tc2-hat-trap-cli/main.go +++ b/internal/tc2-hat-trap-cli/main.go @@ -1,7 +1,10 @@ package trapcli import ( + "bytes" + "compress/flate" "crypto/sha256" + "encoding/base64" "encoding/hex" "encoding/json" "errors" @@ -196,39 +199,36 @@ func Run(inputArgs []string, ver string) error { } } - // Split the file into lines. Removing the trailing newline as that will be added by the RP2040 - lines := strings.Split(strings.TrimSuffix(string(localData), "\n"), "\n") - if len(lines) == 1 && lines[0] == "" { - lines = nil + // Compress with raw DEFLATE and base64 encode for safe UART transfer. + var compressed bytes.Buffer + fw, err := flate.NewWriter(&compressed, flate.BestCompression) + if err != nil { + return fmt.Errorf("failed to create compressor: %v", err) + } + if _, err := fw.Write(localData); err != nil { + return fmt.Errorf("failed to compress file: %v", err) + } + if err := fw.Close(); err != nil { + return fmt.Errorf("failed to finalize compression: %v", err) } + encoded := base64.StdEncoding.EncodeToString(compressed.Bytes()) + fmt.Printf("Original: %d bytes, Compressed: %d bytes (%.0f%%)\n", len(localData), compressed.Len(), float64(compressed.Len())/float64(len(localData))*100) - for _, line := range lines { - lineSend := []string{line} - chunk, err := json.Marshal(lineSend) + // Write base64-encoded compressed data in chunks + const chunkSize = 500 + for i := 0; i < len(encoded); i += chunkSize { + chunk, err := json.Marshal([]string{encoded[i:min(i+chunkSize, len(encoded))]}) if err != nil { return fmt.Errorf("failed to marshal chunk: %v", err) } if err := respond(sendMessage(comms.Message{Type: "WRITE", Payload: tmpBase + "," + string(chunk)}, port)); err != nil { - return fmt.Errorf("failed to write chunk at line %s: %v", line, err) + return fmt.Errorf("failed to write chunk at offset %d: %v", i, err) } } - // Verify temp file. - lsResp2, err := sendMessage(comms.Message{Type: "LS", Payload: tmpBase}, port) - if err != nil { - return fmt.Errorf("failed to verify file: %v", err) - } - var fileHashes2 map[string]string - if err := json.Unmarshal([]byte(lsResp2.Payload), &fileHashes2); err != nil { - return fmt.Errorf("failed to parse verify LS response: %v", err) - } - if fileHashes2[tmpBase] != localHash { - return fmt.Errorf("file verification failed: hash mismatch") - } - - // Move the temp file to the destination - if err := respond(sendMessage(comms.Message{Type: "MV", Payload: tmpBase + "," + destBase}, port)); err != nil { - return fmt.Errorf("failed to move file: %v", err) + // Decompress the temp file into the destination + if err := respond(sendMessage(comms.Message{Type: "DECOMPRESS", Payload: tmpBase + "," + destBase}, port)); err != nil { + return fmt.Errorf("failed to decompress file: %v", err) } // Verify the final file From 6da5b8db9853ad890b04bab82a3691fce56d81d4 Mon Sep 17 00:00:00 2001 From: Cameron Date: Fri, 29 May 2026 20:12:38 +1200 Subject: [PATCH 03/10] Add copy folder --- internal/tc2-hat-trap-cli/main.go | 202 +++++++++++++++++------------- 1 file changed, 113 insertions(+), 89 deletions(-) diff --git a/internal/tc2-hat-trap-cli/main.go b/internal/tc2-hat-trap-cli/main.go index 7712801..cd7661f 100644 --- a/internal/tc2-hat-trap-cli/main.go +++ b/internal/tc2-hat-trap-cli/main.go @@ -35,6 +35,7 @@ type Args struct { Listen *Listen `arg:"subcommand:listen" help:"Continuously listen for messages from the RP2040."` Message *CMDMessage `arg:"subcommand:msg" help:"Send a message to the RP2040."` CopyFile *CopyFile `arg:"subcommand:copy-file" help:"Copy a file to the RP2040."` + CopyDir *CopyDir `arg:"subcommand:copy-dir" help:"Copy all files from a directory to the RP2040."` BaudRate int `arg:"--baud-rate" help:"Baud rate for UART communication."` goconfig.ConfigArgs logging.LogArgs @@ -51,6 +52,11 @@ type CopyFile struct { Dest string `arg:"--dest,required" help:"The destination file to copy to."` } +type CopyDir struct { + Source string `arg:"--source,required" help:"The source directory to copy files from."` + Dest string `arg:"--dest,required" help:"The destination directory on the RP2040."` +} + type Command struct { Command string `arg:"--command,required" help:"The command to run."` } @@ -70,23 +76,31 @@ var defaultArgs = Args{ BaudRate: 9600, } +const maxRetries = 3 + func sendMessage(msg comms.Message, port *serialhelper.SerialPort) (*comms.Message, error) { line := msg.ToUARTLine() - log.Println("Sending:", strings.TrimSpace(line)) - - if err := port.Write([]byte(line)); err != nil { - return nil, err - } - - select { - case line, ok := <-port.Lines: - if !ok { - return nil, fmt.Errorf("serial port closed while waiting for response") + var lastErr error + for i := range maxRetries { + if i > 0 { + log.Warnf("Retrying (%d/%d): %s", i, maxRetries-1, strings.TrimSpace(line)) + } else { + log.Println("Sending:", strings.TrimSpace(line)) + } + if err := port.Write([]byte(line)); err != nil { + return nil, err + } + select { + case line, ok := <-port.Lines: + if !ok { + return nil, fmt.Errorf("serial port closed while waiting for response") + } + return comms.ParseLine(line) + case <-time.After(5 * time.Second): + lastErr = fmt.Errorf("timeout waiting for response") } - return comms.ParseLine(line) - case <-time.After(5 * time.Second): - return nil, fmt.Errorf("timeout waiting for response") } + return nil, lastErr } func procArgs(input []string) (Args, error) { @@ -163,88 +177,23 @@ func Run(inputArgs []string, ver string) error { return respond(sendMessage(message, port)) case args.CopyFile != nil: - localFile := args.CopyFile.Source - destFile := args.CopyFile.Dest + return copyFile(args.CopyFile.Source, args.CopyFile.Dest, port) - // Check that local file exists. - localData, err := os.ReadFile(localFile) + case args.CopyDir != nil: + entries, err := os.ReadDir(args.CopyDir.Source) if err != nil { - return fmt.Errorf("failed to read local file %s: %v", localFile, err) + return fmt.Errorf("failed to read directory %s: %v", args.CopyDir.Source, err) } - - // Calculate the hash of the file, just he first 10 characters. - h := sha256.Sum256(localData) - localHash := hex.EncodeToString(h[:])[:10] - destBase := filepath.Base(destFile) - tmpBase := destBase + ".tmp" - - // Check if the files already matches - lsResp, err := sendMessage(comms.Message{Type: "LS", Payload: destBase + "," + tmpBase}, port) - if err != nil { - return fmt.Errorf("failed to list files: %v", err) - } - var fileHashes map[string]string - if err := json.Unmarshal([]byte(lsResp.Payload), &fileHashes); err != nil { - return fmt.Errorf("failed to parse LS response: %v", err) - } - if fileHashes[destBase] == localHash { - log.Printf("File %s already up to date", destFile) - return nil - } - - // Delete the temp file if it already exists - if _, ok := fileHashes[tmpBase]; ok { - if err := respond(sendMessage(comms.Message{Type: "DELETE", Payload: tmpBase}, port)); err != nil { - return fmt.Errorf("failed to delete temp file: %v", err) - } - } - - // Compress with raw DEFLATE and base64 encode for safe UART transfer. - var compressed bytes.Buffer - fw, err := flate.NewWriter(&compressed, flate.BestCompression) - if err != nil { - return fmt.Errorf("failed to create compressor: %v", err) - } - if _, err := fw.Write(localData); err != nil { - return fmt.Errorf("failed to compress file: %v", err) - } - if err := fw.Close(); err != nil { - return fmt.Errorf("failed to finalize compression: %v", err) - } - encoded := base64.StdEncoding.EncodeToString(compressed.Bytes()) - fmt.Printf("Original: %d bytes, Compressed: %d bytes (%.0f%%)\n", len(localData), compressed.Len(), float64(compressed.Len())/float64(len(localData))*100) - - // Write base64-encoded compressed data in chunks - const chunkSize = 500 - for i := 0; i < len(encoded); i += chunkSize { - chunk, err := json.Marshal([]string{encoded[i:min(i+chunkSize, len(encoded))]}) - if err != nil { - return fmt.Errorf("failed to marshal chunk: %v", err) + for _, entry := range entries { + if entry.IsDir() { + continue } - if err := respond(sendMessage(comms.Message{Type: "WRITE", Payload: tmpBase + "," + string(chunk)}, port)); err != nil { - return fmt.Errorf("failed to write chunk at offset %d: %v", i, err) + localFile := filepath.Join(args.CopyDir.Source, entry.Name()) + destFile := filepath.Join(args.CopyDir.Dest, entry.Name()) + if err := copyFile(localFile, destFile, port); err != nil { + return fmt.Errorf("failed to copy %s: %v", entry.Name(), err) } } - - // Decompress the temp file into the destination - if err := respond(sendMessage(comms.Message{Type: "DECOMPRESS", Payload: tmpBase + "," + destBase}, port)); err != nil { - return fmt.Errorf("failed to decompress file: %v", err) - } - - // Verify the final file - lsResp3, err := sendMessage(comms.Message{Type: "LS", Payload: destBase}, port) - if err != nil { - return fmt.Errorf("failed to verify file: %v", err) - } - var fileHashes3 map[string]string - if err := json.Unmarshal([]byte(lsResp3.Payload), &fileHashes3); err != nil { - return fmt.Errorf("failed to parse verify LS response: %v", err) - } - if fileHashes3[destBase] != localHash { - return fmt.Errorf("file verification failed: hash mismatch. Got %s, expected %s", fileHashes3[tmpBase], localHash) - } - - log.Printf("File %s copied successfully", destFile) return nil default: @@ -262,3 +211,78 @@ func respond(response *comms.Message, err error) error { log.Infof("Response: type=%s, payload=%s", response.Type, response.Payload) return nil } + +func copyFile(localFile, destFile string, port *serialhelper.SerialPort) error { + localData, err := os.ReadFile(localFile) + if err != nil { + return fmt.Errorf("failed to read local file %s: %v", localFile, err) + } + + h := sha256.Sum256(localData) + localHash := hex.EncodeToString(h[:])[:10] + destBase := filepath.Base(destFile) + tmpBase := destBase + ".tmp" + + lsResp, err := sendMessage(comms.Message{Type: "LS", Payload: destBase + "," + tmpBase}, port) + if err != nil { + return fmt.Errorf("failed to list files: %v", err) + } + var fileHashes map[string]string + if err := json.Unmarshal([]byte(lsResp.Payload), &fileHashes); err != nil { + return fmt.Errorf("failed to parse LS response: %v", err) + } + if fileHashes[destBase] == localHash { + log.Printf("File %s already up to date", destFile) + //return nil + } + + if _, ok := fileHashes[tmpBase]; ok { + if err := respond(sendMessage(comms.Message{Type: "DELETE", Payload: tmpBase}, port)); err != nil { + return fmt.Errorf("failed to delete temp file: %v", err) + } + } + + var compressed bytes.Buffer + fw, err := flate.NewWriter(&compressed, flate.HuffmanOnly) + if err != nil { + return fmt.Errorf("failed to create compressor: %v", err) + } + if _, err := fw.Write(localData); err != nil { + return fmt.Errorf("failed to compress file: %v", err) + } + if err := fw.Close(); err != nil { + return fmt.Errorf("failed to finalize compression: %v", err) + } + encoded := base64.StdEncoding.EncodeToString(compressed.Bytes()) + fmt.Printf("%s: %d bytes -> %d bytes compressed (%.0f%%)\n", filepath.Base(localFile), len(localData), compressed.Len(), float64(compressed.Len())/float64(len(localData))*100) + + const chunkSize = 500 + for i := 0; i < len(encoded); i += chunkSize { + chunk, err := json.Marshal([]string{encoded[i:min(i+chunkSize, len(encoded))]}) + if err != nil { + return fmt.Errorf("failed to marshal chunk: %v", err) + } + if err := respond(sendMessage(comms.Message{Type: "WRITE", Payload: tmpBase + "," + string(chunk)}, port)); err != nil { + return fmt.Errorf("failed to write chunk at offset %d: %v", i, err) + } + } + + if err := respond(sendMessage(comms.Message{Type: "DECOMPRESS", Payload: tmpBase + "," + destBase}, port)); err != nil { + return fmt.Errorf("failed to decompress file: %v", err) + } + + lsResp2, err := sendMessage(comms.Message{Type: "LS", Payload: destBase}, port) + if err != nil { + return fmt.Errorf("failed to verify file: %v", err) + } + var fileHashes2 map[string]string + if err := json.Unmarshal([]byte(lsResp2.Payload), &fileHashes2); err != nil { + return fmt.Errorf("failed to parse verify LS response: %v", err) + } + if fileHashes2[destBase] != localHash { + return fmt.Errorf("file verification failed: hash mismatch") + } + + log.Printf("File %s copied successfully", destFile) + return nil +} From 86d3ce13cb95723be7c9720d52a9b01acb064108 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sat, 30 May 2026 12:02:42 +1200 Subject: [PATCH 04/10] Improve logging --- internal/tc2-hat-trap-cli/main.go | 39 +++++++++++++++++++------------ 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/internal/tc2-hat-trap-cli/main.go b/internal/tc2-hat-trap-cli/main.go index cd7661f..f2003d2 100644 --- a/internal/tc2-hat-trap-cli/main.go +++ b/internal/tc2-hat-trap-cli/main.go @@ -85,7 +85,7 @@ func sendMessage(msg comms.Message, port *serialhelper.SerialPort) (*comms.Messa if i > 0 { log.Warnf("Retrying (%d/%d): %s", i, maxRetries-1, strings.TrimSpace(line)) } else { - log.Println("Sending:", strings.TrimSpace(line)) + //log.Println("Sending:", strings.TrimSpace(line)) } if err := port.Write([]byte(line)); err != nil { return nil, err @@ -116,7 +116,7 @@ func procArgs(input []string) (Args, error) { os.Exit(0) } if errors.Is(err, arg.ErrVersion) { - fmt.Println(version) + log.Infoln(version) os.Exit(0) } return args, err @@ -139,11 +139,11 @@ func Run(inputArgs []string, ver string) error { switch { case args.Listen != nil: - fmt.Println("Listening for messages from RP2040 (Ctrl+C to stop)...") + log.Infoln("Listening for messages from RP2040 (Ctrl+C to stop)...") for line := range port.Lines { msg, err := comms.ParseLine(line) if err != nil { - fmt.Printf("raw: %s\n", line) + log.Infof("raw: %s\n", line) log.Warnf("Failed to parse incoming message %q: %v", line, err) continue } @@ -208,11 +208,13 @@ func respond(response *comms.Message, err error) error { if response.Type == "NACK" { return fmt.Errorf("NACK response: %s", response.Payload) } - log.Infof("Response: type=%s, payload=%s", response.Type, response.Payload) + //log.Infof("Response: type=%s, payload=%s", response.Type, response.Payload) return nil } func copyFile(localFile, destFile string, port *serialhelper.SerialPort) error { + log.Printf("Uploading '%s'", destFile) + localData, err := os.ReadFile(localFile) if err != nil { return fmt.Errorf("failed to read local file %s: %v", localFile, err) @@ -221,9 +223,10 @@ func copyFile(localFile, destFile string, port *serialhelper.SerialPort) error { h := sha256.Sum256(localData) localHash := hex.EncodeToString(h[:])[:10] destBase := filepath.Base(destFile) + compressedBase := destBase + ".ztmp" tmpBase := destBase + ".tmp" - lsResp, err := sendMessage(comms.Message{Type: "LS", Payload: destBase + "," + tmpBase}, port) + lsResp, err := sendMessage(comms.Message{Type: "LS", Payload: destBase + "," + compressedBase}, port) if err != nil { return fmt.Errorf("failed to list files: %v", err) } @@ -232,12 +235,13 @@ func copyFile(localFile, destFile string, port *serialhelper.SerialPort) error { return fmt.Errorf("failed to parse LS response: %v", err) } if fileHashes[destBase] == localHash { - log.Printf("File %s already up to date", destFile) + log.Printf("\tFile is already up to date.") + // TODO Add force upload option here. //return nil } - if _, ok := fileHashes[tmpBase]; ok { - if err := respond(sendMessage(comms.Message{Type: "DELETE", Payload: tmpBase}, port)); err != nil { + if _, ok := fileHashes[compressedBase]; ok { + if err := respond(sendMessage(comms.Message{Type: "DELETE", Payload: compressedBase}, port)); err != nil { return fmt.Errorf("failed to delete temp file: %v", err) } } @@ -254,24 +258,29 @@ func copyFile(localFile, destFile string, port *serialhelper.SerialPort) error { return fmt.Errorf("failed to finalize compression: %v", err) } encoded := base64.StdEncoding.EncodeToString(compressed.Bytes()) - fmt.Printf("%s: %d bytes -> %d bytes compressed (%.0f%%)\n", filepath.Base(localFile), len(localData), compressed.Len(), float64(compressed.Len())/float64(len(localData))*100) + log.Infof("\t%d bytes -> %d bytes compressed (%.0f%%)", len(localData), compressed.Len(), float64(compressed.Len())/float64(len(localData))*100) const chunkSize = 500 + totalChunks := (len(encoded) + chunkSize - 1) / chunkSize for i := 0; i < len(encoded); i += chunkSize { + chunkNum := i/chunkSize + 1 + log.Infof("\t%s: %d/%d", filepath.Base(localFile), chunkNum, totalChunks) chunk, err := json.Marshal([]string{encoded[i:min(i+chunkSize, len(encoded))]}) if err != nil { return fmt.Errorf("failed to marshal chunk: %v", err) } - if err := respond(sendMessage(comms.Message{Type: "WRITE", Payload: tmpBase + "," + string(chunk)}, port)); err != nil { + if err := respond(sendMessage(comms.Message{Type: "WRITE", Payload: compressedBase + "," + string(chunk)}, port)); err != nil { return fmt.Errorf("failed to write chunk at offset %d: %v", i, err) } } - if err := respond(sendMessage(comms.Message{Type: "DECOMPRESS", Payload: tmpBase + "," + destBase}, port)); err != nil { + log.Println("\tDecompressing...") + if err := respond(sendMessage(comms.Message{Type: "DECOMPRESS", Payload: compressedBase + "," + tmpBase}, port)); err != nil { return fmt.Errorf("failed to decompress file: %v", err) } - lsResp2, err := sendMessage(comms.Message{Type: "LS", Payload: destBase}, port) + log.Println("\tVerifying...") + lsResp2, err := sendMessage(comms.Message{Type: "LS", Payload: tmpBase}, port) if err != nil { return fmt.Errorf("failed to verify file: %v", err) } @@ -279,10 +288,10 @@ func copyFile(localFile, destFile string, port *serialhelper.SerialPort) error { if err := json.Unmarshal([]byte(lsResp2.Payload), &fileHashes2); err != nil { return fmt.Errorf("failed to parse verify LS response: %v", err) } - if fileHashes2[destBase] != localHash { + if fileHashes2[tmpBase] != localHash { return fmt.Errorf("file verification failed: hash mismatch") } - log.Printf("File %s copied successfully", destFile) + log.Printf("\tFile '%s' copied successfully.", tmpBase) return nil } From 7815b156cca97b6b150bc7291f9f0a2a0f689f02 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sat, 30 May 2026 19:57:20 +1200 Subject: [PATCH 05/10] Change file upload process --- internal/tc2-hat-trap-cli/main.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/internal/tc2-hat-trap-cli/main.go b/internal/tc2-hat-trap-cli/main.go index f2003d2..f632907 100644 --- a/internal/tc2-hat-trap-cli/main.go +++ b/internal/tc2-hat-trap-cli/main.go @@ -177,7 +177,10 @@ func Run(inputArgs []string, ver string) error { return respond(sendMessage(message, port)) case args.CopyFile != nil: - return copyFile(args.CopyFile.Source, args.CopyFile.Dest, port) + if err := copyFile(args.CopyFile.Source, args.CopyFile.Dest, port); err != nil { + return err + } + return commitFiles(port) case args.CopyDir != nil: entries, err := os.ReadDir(args.CopyDir.Source) @@ -194,13 +197,18 @@ func Run(inputArgs []string, ver string) error { return fmt.Errorf("failed to copy %s: %v", entry.Name(), err) } } - return nil + return commitFiles(port) default: return fmt.Errorf("no subcommand given") } } +func commitFiles(port *serialhelper.SerialPort) error { + log.Println("Committing files...") + return respond(sendMessage(comms.Message{Type: "COMMIT"}, port)) +} + func respond(response *comms.Message, err error) error { if err != nil { return err From ad40e0835619281f4bb47afddcf453f4d9e89db8 Mon Sep 17 00:00:00 2001 From: Cameron Date: Mon, 1 Jun 2026 11:12:05 +1200 Subject: [PATCH 06/10] Add RESTART command and --force option for uploading files --- internal/tc2-hat-trap-cli/main.go | 46 ++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/internal/tc2-hat-trap-cli/main.go b/internal/tc2-hat-trap-cli/main.go index f632907..1b5624e 100644 --- a/internal/tc2-hat-trap-cli/main.go +++ b/internal/tc2-hat-trap-cli/main.go @@ -36,6 +36,7 @@ type Args struct { Message *CMDMessage `arg:"subcommand:msg" help:"Send a message to the RP2040."` CopyFile *CopyFile `arg:"subcommand:copy-file" help:"Copy a file to the RP2040."` CopyDir *CopyDir `arg:"subcommand:copy-dir" help:"Copy all files from a directory to the RP2040."` + Restart *Restart `arg:"subcommand:restart" help:"Restart the RP2040."` BaudRate int `arg:"--baud-rate" help:"Baud rate for UART communication."` goconfig.ConfigArgs logging.LogArgs @@ -50,11 +51,13 @@ type CMDMessage struct { type CopyFile struct { Source string `arg:"--source,required" help:"The source file to copy."` Dest string `arg:"--dest,required" help:"The destination file to copy to."` + Force bool `arg:"--force" help:"Force overwrite of the destination file."` } type CopyDir struct { Source string `arg:"--source,required" help:"The source directory to copy files from."` Dest string `arg:"--dest,required" help:"The destination directory on the RP2040."` + Force bool `arg:"--force" help:"Force overwrite of the files."` } type Command struct { @@ -72,6 +75,8 @@ type Write struct { type Listen struct{} +type Restart struct{} + var defaultArgs = Args{ BaudRate: 9600, } @@ -139,7 +144,7 @@ func Run(inputArgs []string, ver string) error { switch { case args.Listen != nil: - log.Infoln("Listening for messages from RP2040 (Ctrl+C to stop)...") + log.Info("Listening for messages from RP2040 (Ctrl+C to stop)...") for line := range port.Lines { msg, err := comms.ParseLine(line) if err != nil { @@ -176,8 +181,11 @@ func Run(inputArgs []string, ver string) error { message := comms.Message{ID: args.Message.ID, Type: args.Message.Type, Payload: args.Message.Payload} return respond(sendMessage(message, port)) + case args.Restart != nil: + return respond(sendMessage(comms.Message{Type: "RESTART"}, port)) + case args.CopyFile != nil: - if err := copyFile(args.CopyFile.Source, args.CopyFile.Dest, port); err != nil { + if err := copyFile(args.CopyFile.Source, args.CopyFile.Dest, port, args.CopyFile.Force); err != nil { return err } return commitFiles(port) @@ -193,7 +201,7 @@ func Run(inputArgs []string, ver string) error { } localFile := filepath.Join(args.CopyDir.Source, entry.Name()) destFile := filepath.Join(args.CopyDir.Dest, entry.Name()) - if err := copyFile(localFile, destFile, port); err != nil { + if err := copyFile(localFile, destFile, port, args.CopyDir.Force); err != nil { return fmt.Errorf("failed to copy %s: %v", entry.Name(), err) } } @@ -205,7 +213,7 @@ func Run(inputArgs []string, ver string) error { } func commitFiles(port *serialhelper.SerialPort) error { - log.Println("Committing files...") + log.Println("Committing all .tmp files...") return respond(sendMessage(comms.Message{Type: "COMMIT"}, port)) } @@ -216,12 +224,15 @@ func respond(response *comms.Message, err error) error { if response.Type == "NACK" { return fmt.Errorf("NACK response: %s", response.Payload) } - //log.Infof("Response: type=%s, payload=%s", response.Type, response.Payload) + log.Infof("Response: type=%s, payload=%s", response.Type, response.Payload) return nil } -func copyFile(localFile, destFile string, port *serialhelper.SerialPort) error { - log.Printf("Uploading '%s'", destFile) +func copyFile(localFile, destFile string, port *serialhelper.SerialPort, force bool) error { + destBase := filepath.Base(destFile) + compressedBase := destBase + ".ztmp" + tmpBase := destBase + ".tmp" + log.Printf("Uploading '%s' as '%s'", destFile, tmpBase) localData, err := os.ReadFile(localFile) if err != nil { @@ -230,11 +241,8 @@ func copyFile(localFile, destFile string, port *serialhelper.SerialPort) error { h := sha256.Sum256(localData) localHash := hex.EncodeToString(h[:])[:10] - destBase := filepath.Base(destFile) - compressedBase := destBase + ".ztmp" - tmpBase := destBase + ".tmp" - lsResp, err := sendMessage(comms.Message{Type: "LS", Payload: destBase + "," + compressedBase}, port) + lsResp, err := sendMessage(comms.Message{Type: "LS", Payload: destBase + "," + compressedBase + "," + tmpBase}, port) if err != nil { return fmt.Errorf("failed to list files: %v", err) } @@ -242,12 +250,24 @@ func copyFile(localFile, destFile string, port *serialhelper.SerialPort) error { if err := json.Unmarshal([]byte(lsResp.Payload), &fileHashes); err != nil { return fmt.Errorf("failed to parse LS response: %v", err) } + + // Check if file or tmp file is already up to date if fileHashes[destBase] == localHash { log.Printf("\tFile is already up to date.") - // TODO Add force upload option here. - //return nil + if !force { + return nil + } + log.Println("\tForce flag is set, still uploading.") + } + if fileHashes[tmpBase] == localHash { + log.Printf("\t.tmp file is already up to date.") + if !force { + return nil + } + log.Println("\tForce flag is set, still uploading.") } + // Delete old ztmp file if it exists if _, ok := fileHashes[compressedBase]; ok { if err := respond(sendMessage(comms.Message{Type: "DELETE", Payload: compressedBase}, port)); err != nil { return fmt.Errorf("failed to delete temp file: %v", err) From fb2b5ee4b100c40f56b181300212768e7d24c5bc Mon Sep 17 00:00:00 2001 From: Cameron Date: Mon, 1 Jun 2026 11:28:22 +1200 Subject: [PATCH 07/10] Add write and read time commands --- internal/tc2-hat-trap-cli/main.go | 37 +++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/internal/tc2-hat-trap-cli/main.go b/internal/tc2-hat-trap-cli/main.go index 1b5624e..1607dd5 100644 --- a/internal/tc2-hat-trap-cli/main.go +++ b/internal/tc2-hat-trap-cli/main.go @@ -29,15 +29,17 @@ var ( ) type Args struct { - Command *Command `arg:"subcommand:command" help:"Send a command."` - Read *Read `arg:"subcommand:read" help:"Read from a variable."` - Write *Write `arg:"subcommand:write" help:"Write to a variable."` - Listen *Listen `arg:"subcommand:listen" help:"Continuously listen for messages from the RP2040."` - Message *CMDMessage `arg:"subcommand:msg" help:"Send a message to the RP2040."` - CopyFile *CopyFile `arg:"subcommand:copy-file" help:"Copy a file to the RP2040."` - CopyDir *CopyDir `arg:"subcommand:copy-dir" help:"Copy all files from a directory to the RP2040."` - Restart *Restart `arg:"subcommand:restart" help:"Restart the RP2040."` - BaudRate int `arg:"--baud-rate" help:"Baud rate for UART communication."` + Command *Command `arg:"subcommand:command" help:"Send a command."` + Read *Read `arg:"subcommand:read" help:"Read from a variable."` + Write *Write `arg:"subcommand:write" help:"Write to a variable."` + Listen *Listen `arg:"subcommand:listen" help:"Continuously listen for messages from the RP2040."` + Message *CMDMessage `arg:"subcommand:msg" help:"Send a message to the RP2040."` + CopyFile *CopyFile `arg:"subcommand:copy-file" help:"Copy a file to the RP2040."` + CopyDir *CopyDir `arg:"subcommand:copy-dir" help:"Copy all files from a directory to the RP2040."` + Restart *Restart `arg:"subcommand:restart" help:"Restart the RP2040."` + ReadTime *ReadTime `arg:"subcommand:read-time" help:"Read the time from the RP2040."` + WriteTime *WriteTime `arg:"subcommand:write-time" help:"Write the time to the RP2040."` + BaudRate int `arg:"--baud-rate" help:"Baud rate for UART communication."` goconfig.ConfigArgs logging.LogArgs } @@ -73,6 +75,12 @@ type Write struct { Value string `arg:"--value,required" help:"The value to write."` } +type ReadTime struct{} + +type WriteTime struct { + Time string `arg:"--time" help:"The time to write."` +} + type Listen struct{} type Restart struct{} @@ -207,6 +215,17 @@ func Run(inputArgs []string, ver string) error { } return commitFiles(port) + case args.ReadTime != nil: + return respond(sendMessage(comms.Message{Type: "READ_TIME"}, port)) + + case args.WriteTime != nil: + timeStr := time.Now().UTC().Format(time.DateTime) + if args.WriteTime.Time != "" { + timeStr = args.WriteTime.Time + } + log.Printf("Writing UTC time: '%s'", timeStr) + return respond(sendMessage(comms.Message{Type: "WRITE_TIME", Payload: timeStr}, port)) + default: return fmt.Errorf("no subcommand given") } From beacc4d9694719b073c6925014ed5498916fc3b2 Mon Sep 17 00:00:00 2001 From: Cameron Date: Tue, 2 Jun 2026 22:55:13 +1200 Subject: [PATCH 08/10] Code tidy up --- .gitignore | 1 + .goreleaser.yml | 2 + internal/tc2-hat-comms/trap-control.go | 184 ++++---------- internal/tc2-hat-comms/trap-messenger.go | 293 +++++++++++++++++++++++ internal/tc2-hat-trap-cli/main.go | 235 ++---------------- 5 files changed, 374 insertions(+), 341 deletions(-) create mode 100644 internal/tc2-hat-comms/trap-messenger.go diff --git a/.gitignore b/.gitignore index f0b6b21..25532b8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /dist _release/attiny-firmware.hex _release/attiny-firmware.hex.sha256 +_release/mpy /tc2-hat-controller diff --git a/.goreleaser.yml b/.goreleaser.yml index b88333a..a7f2371 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -79,6 +79,8 @@ nfpms: dst: /usr/bin/tc2-hat-temp - src: _release/tc2-hat-trap-cli dst: /usr/bin/tc2-hat-trap-cli + - src: _release/mpy/* + dst: /etc/cacophony/mpy dependencies: #- python3-pip diff --git a/internal/tc2-hat-comms/trap-control.go b/internal/tc2-hat-comms/trap-control.go index 19a251c..94484d8 100644 --- a/internal/tc2-hat-comms/trap-control.go +++ b/internal/tc2-hat-comms/trap-control.go @@ -7,7 +7,6 @@ import ( "encoding/json" "fmt" "strconv" - "sync" "time" "github.com/TheCacophonyProject/event-reporter/v3/eventclient" @@ -18,6 +17,56 @@ import ( // processTrapControl communicates the trap enabled/disabled state by writing // the "enable" variable over UART instead of setting a digital pin. func processTrapControl(config *CommsConfig, eventSignals chan event) error { + // Open the serial port so we can send/receive messages from the trap. + port, err := serialhelper.OpenSerial(gpio.High, gpio.Low, config.BaudRate) + if err != nil { + return fmt.Errorf("failed to open serial port: %v", err) + } + defer port.Close() + + messenger := NewTrapMessenger(port) + messenger.UnsolicitedHandler = parseMessageFromTrap + messenger.Start() + + // Wait to get PING response from trap + log.Info("Waiting for PING response from trap...") + for { + if err := messenger.Ping(); err == nil { + break + } + log.Warnf("Failed to get PING response from trap: %v", err) + time.Sleep(time.Second) + } + eventclient.AddEvent(eventclient.Event{ + Timestamp: time.Now(), + Type: "trapPing", + }) + log.Info("PING response received from trap.") + + // Make sure it is running the latest software + log.Info("Checking trap software is up to date") + fileUpdated, err := messenger.CopyDir("/etc/cacophony/mpy", "/", false) + if err != nil { + log.Error("Error in uploading the latest software to the trap") + return err + } + if fileUpdated { + log.Info("Updated software on trap") + } else { + log.Info("Software already up to date on trap") + } + + // Setup loop for monitoring classifications and enabling/disabling the trap + if err := classificationChecks(config, eventSignals, messenger); err != nil { + log.Errorf("Failed to run classification checks: %v", err) + return err + } + + return nil +} + +func classificationChecks(config *CommsConfig, eventSignals chan event, messenger *TrapMessenger) error { + trapEnabled := false previousTrapEnabled := false lastProtectSpeciesSighting := time.Time{} @@ -30,17 +79,6 @@ func processTrapControl(config *CommsConfig, eventSignals chan event) error { triggerAnimal := "" var confidence int32 - // Open the serial port so we can send/receive messages from the trap. - port, err := serialhelper.OpenSerial(gpio.High, gpio.Low, config.BaudRate) - if err != nil { - return fmt.Errorf("failed to open serial port: %v", err) - } - defer port.Close() - - // Create the messenger that tracks sending/receiving messages - messenger := NewUartMessenger(port) - messenger.Start() - for { now := time.Now() trapEnabled = config.TrapEnabledByDefault @@ -54,7 +92,7 @@ func processTrapControl(config *CommsConfig, eventSignals chan event) error { if trapEnabled != previousTrapEnabled { if trapEnabled { log.Infof("Enabling trap, reason: %s", enablingReason) - success, err := messenger.setEnable(true) + success, err := messenger.SetEnable(true) if err != nil { return fmt.Errorf("failed to enable trap: %v", err) } @@ -81,7 +119,7 @@ func processTrapControl(config *CommsConfig, eventSignals chan event) error { }) } else { log.Info("Disabling trap, reason: ", disablingReason) - success, err := messenger.setEnable(false) + success, err := messenger.SetEnable(false) if err != nil { return fmt.Errorf("failed to disable trap: %v", err) } @@ -151,6 +189,7 @@ func processTrapControl(config *CommsConfig, eventSignals chan event) error { log.Debug("Scheduled check") } } + } // Message represents the data structure for communication with a device connected on UART. @@ -202,69 +241,6 @@ type Write struct { Val any `json:"val,omitempty"` } -// UartMessenger manages bidirectional communication with the RP2040 over UART. -// It holds a persistent serial port and routes incoming messages to either -// pending response waiters (matched by ID) or an unsolicited message channel. -type UartMessenger struct { - port *serialhelper.SerialPort - pendingMu sync.Mutex - pending map[int]chan *Message - nextID int - baudRate int -} - -// NewUartMessenger creates a UartMessenger using an already-open SerialPort. -func NewUartMessenger(port *serialhelper.SerialPort) *UartMessenger { - return &UartMessenger{ - port: port, - pending: make(map[int]chan *Message), - } -} - -// Start begins the background routing goroutine. Unsolicited messages from the RP2040 -// (i.e. not responses to a request we sent) are delivered to the unsolicited channel. -// Pass nil to discard unsolicited messages. -func (u *UartMessenger) Start() { - go u.routeMessages() -} - -// routeMessages reads lines from the serial port, parses them, and routes them: -// TODO: Maybe separate this for routing messages -// - Response messages are matched to a pending sendMessage call by ID. -// - If not a response then it is a notification from the trap. -func (u *UartMessenger) routeMessages() { - for line := range u.port.Lines { - // Parse the line - msg, err := ParseLine(line) - if err != nil { - log.Warnf("Failed to parse incoming message %q: %v", line, err) - continue - } - - // Check if the message was a response - if msg.Response() { - u.pendingMu.Lock() - ch, ok := u.pending[msg.ID] - if !ok && len(u.pending) == 1 { - // Fallback for RP2040 firmware that doesn't echo message IDs yet. - for _, c := range u.pending { - ch = c - ok = true - break - } - } - u.pendingMu.Unlock() - if ok { - ch <- msg - continue - } - } - - // If not a response then it is a notification from the trap. - parseMessageFromTrap(msg) - } -} - func parseMessageFromTrap(msg *Message) { log.Printf("Trap message: %+v", msg) @@ -385,57 +361,3 @@ func computeChecksum(message []byte) int { } return checksum % 256 } - -// sendMessage sends a request and waits for a matching response. -// It assigns a unique ID to the message for correlation. -func (u *UartMessenger) sendMessage(message Message) (*Message, error) { - u.pendingMu.Lock() - u.nextID++ - id := u.nextID - message.ID = id - ch := make(chan *Message, 1) - u.pending[id] = ch - u.pendingMu.Unlock() - - defer func() { - u.pendingMu.Lock() - delete(u.pending, id) - u.pendingMu.Unlock() - }() - - line := message.ToUARTLine() - log.Infof("Message: '%s'", line) - - if err := u.port.Write([]byte(line)); err != nil { - return nil, err - } - - select { - case response := <-ch: - log.Println("Response:", response) - return response, nil - case <-time.After(5 * time.Second): - return nil, fmt.Errorf("timeout waiting for response to message ID %d", id) - } -} - -func (u *UartMessenger) setEnable(enable bool) (bool, error) { - message := Message{} - if enable { - message.Type = "ENABLE" - } else { - message.Type = "DISABLE" - } - response, err := u.sendMessage(message) - if err != nil { - return false, err - } - if response.Type == "NACK" { - return false, fmt.Errorf("NACK response") - } - if response.Type == "BAD_KEY" { - log.Warn("Got BAD_KEY response, was trying to set a key that doesn't exist") - return false, nil - } - return true, nil -} diff --git a/internal/tc2-hat-comms/trap-messenger.go b/internal/tc2-hat-comms/trap-messenger.go new file mode 100644 index 0000000..cffcd48 --- /dev/null +++ b/internal/tc2-hat-comms/trap-messenger.go @@ -0,0 +1,293 @@ +package comms + +import ( + "bytes" + "compress/flate" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/TheCacophonyProject/tc2-hat-controller/serialhelper" +) + +// TrapMessenger manages bidirectional communication with the RP2040 over UART. +// It holds a persistent serial port and routes incoming messages to either +// pending response waiters (matched by ID) or an unsolicited message handler. +type TrapMessenger struct { + port *serialhelper.SerialPort + pendingMu sync.Mutex + pending map[int]chan *Message + nextID int + UnsolicitedHandler func(*Message) +} + +// NewTrapMessenger creates a TrapMessenger using an already-open SerialPort. +func NewTrapMessenger(port *serialhelper.SerialPort) *TrapMessenger { + return &TrapMessenger{ + port: port, + pending: make(map[int]chan *Message), + } +} + +// Start begins the background routing goroutine. +func (u *TrapMessenger) Start() { + go u.routeMessages() +} + +func (u *TrapMessenger) routeMessages() { + for line := range u.port.Lines { + msg, err := ParseLine(line) + if err != nil { + log.Warnf("Failed to parse incoming message %q: %v", line, err) + continue + } + + if msg.Response() { + u.pendingMu.Lock() + ch, ok := u.pending[msg.ID] + if !ok && len(u.pending) == 1 { + // Fallback for RP2040 firmware that doesn't echo message IDs yet. + for _, c := range u.pending { + ch = c + ok = true + break + } + } + u.pendingMu.Unlock() + if ok { + ch <- msg + continue + } + } + + if u.UnsolicitedHandler != nil { + u.UnsolicitedHandler(msg) + } + } +} + +// SendMessage sends a request and waits for a matching response. +// It assigns a unique ID to the message for correlation. +func (u *TrapMessenger) SendMessage(message Message) (*Message, error) { + u.pendingMu.Lock() + u.nextID++ + id := u.nextID + message.ID = id + ch := make(chan *Message, 1) + u.pending[id] = ch + u.pendingMu.Unlock() + + defer func() { + u.pendingMu.Lock() + delete(u.pending, id) + u.pendingMu.Unlock() + }() + + line := message.ToUARTLine() + log.Infof("Message: '%s'", line) + + if err := u.port.Write([]byte(line)); err != nil { + return nil, err + } + + select { + case response := <-ch: + log.Println("Response:", response) + return response, nil + case <-time.After(5 * time.Second): + return nil, fmt.Errorf("timeout waiting for response to message ID %d", id) + } +} + +func (u *TrapMessenger) Ping() error { + resp, err := u.SendMessage(Message{Type: "PING"}) + if err != nil { + return err + } + if resp.Type != "ACK" { + return fmt.Errorf("unexpected ping response: %s", resp.Type) + } + return nil +} + +func (u *TrapMessenger) SetEnable(enable bool) (bool, error) { + message := Message{} + if enable { + message.Type = "ENABLE" + } else { + message.Type = "DISABLE" + } + response, err := u.SendMessage(message) + if err != nil { + return false, err + } + if response.Type == "NACK" { + return false, fmt.Errorf("NACK response") + } + if response.Type == "BAD_KEY" { + log.Warn("Got BAD_KEY response, was trying to set a key that doesn't exist") + return false, nil + } + return true, nil +} + +func HandleResponse(response *Message, err error) error { + if err != nil { + return err + } + if response.Type == "NACK" { + return fmt.Errorf("NACK response: %s", response.Payload) + } + log.Infof("Response: type=%s, payload=%s", response.Type, response.Payload) + return nil +} + +func (u *TrapMessenger) Restart() error { + return HandleResponse(u.SendMessage(Message{Type: "RESTART"})) +} + +func (u *TrapMessenger) ReadTime() error { + return HandleResponse(u.SendMessage(Message{Type: "READ_TIME"})) +} + +func (u *TrapMessenger) WriteTime(timeStr string) error { + if timeStr == "" { + timeStr = time.Now().UTC().Format(time.DateTime) + } + log.Printf("Writing UTC time: '%s'", timeStr) + return HandleResponse(u.SendMessage(Message{Type: "WRITE_TIME", Payload: timeStr})) +} + +func (u *TrapMessenger) CommitFiles() error { + log.Println("Committing all .tmp files...") + return HandleResponse(u.SendMessage(Message{Type: "COMMIT"})) +} + +// CopyDir uploads all files from sourceDir to destDir on the RP2040, then commits them. +// Returns true if any file was updated. +// Only files that don't match the hash will be updated unless force is true. +func (u *TrapMessenger) CopyDir(sourceDir, destDir string, force bool) (bool, error) { + aFileWasUpdated := false + entries, err := os.ReadDir(sourceDir) + if err != nil { + return false, fmt.Errorf("failed to read directory %s: %v", sourceDir, err) + } + for _, entry := range entries { + if entry.IsDir() { + // TODO: recursively copy subdirectories + continue + } + localFile := filepath.Join(sourceDir, entry.Name()) + destFile := filepath.Join(destDir, entry.Name()) + fileUpdated, err := u.CopyFile(localFile, destFile, force) + if err != nil { + return false, fmt.Errorf("failed to copy %s: %v", entry.Name(), err) + } + aFileWasUpdated = aFileWasUpdated || fileUpdated + } + return aFileWasUpdated, u.CommitFiles() +} + +// CopyFile uploads a file to the RP2040. +// The file will be written to a .tmp file on the RP2040. Once you want to commit the file change use the COMMIT command. +// It returns a bool that indicates whether the file needed to be updated. +// Only files that don't match the hash will be updated unless force is true. +func (u *TrapMessenger) CopyFile(localFile, destFile string, force bool) (bool, error) { + destBase := filepath.Base(destFile) + compressedBase := destBase + ".ztmp" + tmpBase := destBase + ".tmp" + log.Printf("Uploading '%s' as '%s'", destFile, tmpBase) + + localData, err := os.ReadFile(localFile) + if err != nil { + return false, fmt.Errorf("failed to read local file %s: %v", localFile, err) + } + + h := sha256.Sum256(localData) + localHash := hex.EncodeToString(h[:])[:10] + + lsResp, err := u.SendMessage(Message{Type: "LS", Payload: destBase + "," + compressedBase + "," + tmpBase}) + if err != nil { + return false, fmt.Errorf("failed to list files: %v", err) + } + var fileHashes map[string]string + if err := json.Unmarshal([]byte(lsResp.Payload), &fileHashes); err != nil { + return false, fmt.Errorf("failed to parse LS response: %v", err) + } + + if fileHashes[destBase] == localHash { + log.Printf("\tFile is already up to date.") + if !force { + return false, nil + } + log.Println("\tForce flag is set, still uploading.") + } + if fileHashes[tmpBase] == localHash { + log.Printf("\t.tmp file is already up to date.") + if !force { + return false, nil + } + log.Println("\tForce flag is set, still uploading.") + } + + if _, ok := fileHashes[compressedBase]; ok { + if err := HandleResponse(u.SendMessage(Message{Type: "DELETE", Payload: compressedBase})); err != nil { + return false, fmt.Errorf("failed to delete temp file: %v", err) + } + } + + var compressed bytes.Buffer + fw, err := flate.NewWriter(&compressed, flate.HuffmanOnly) + if err != nil { + return false, fmt.Errorf("failed to create compressor: %v", err) + } + if _, err := fw.Write(localData); err != nil { + return false, fmt.Errorf("failed to compress file: %v", err) + } + if err := fw.Close(); err != nil { + return false, fmt.Errorf("failed to finalize compression: %v", err) + } + encoded := base64.StdEncoding.EncodeToString(compressed.Bytes()) + log.Infof("\t%d bytes -> %d bytes compressed (%.0f%%)", len(localData), compressed.Len(), float64(compressed.Len())/float64(len(localData))*100) + + const chunkSize = 500 + totalChunks := (len(encoded) + chunkSize - 1) / chunkSize + for i := 0; i < len(encoded); i += chunkSize { + chunkNum := i/chunkSize + 1 + log.Infof("\t%s: %d/%d", filepath.Base(localFile), chunkNum, totalChunks) + chunk, err := json.Marshal([]string{encoded[i:min(i+chunkSize, len(encoded))]}) + if err != nil { + return false, fmt.Errorf("failed to marshal chunk: %v", err) + } + if err := HandleResponse(u.SendMessage(Message{Type: "WRITE", Payload: compressedBase + "," + string(chunk)})); err != nil { + return false, fmt.Errorf("failed to write chunk at offset %d: %v", i, err) + } + } + + log.Println("\tDecompressing...") + if err := HandleResponse(u.SendMessage(Message{Type: "DECOMPRESS", Payload: compressedBase + "," + tmpBase})); err != nil { + return false, fmt.Errorf("failed to decompress file: %v", err) + } + + log.Println("\tVerifying...") + lsResp2, err := u.SendMessage(Message{Type: "LS", Payload: tmpBase}) + if err != nil { + return false, fmt.Errorf("failed to verify file: %v", err) + } + var fileHashes2 map[string]string + if err := json.Unmarshal([]byte(lsResp2.Payload), &fileHashes2); err != nil { + return false, fmt.Errorf("failed to parse verify LS response: %v", err) + } + if fileHashes2[tmpBase] != localHash { + return false, fmt.Errorf("file verification failed: hash mismatch") + } + + log.Printf("\tFile '%s' copied successfully.", tmpBase) + return true, nil +} diff --git a/internal/tc2-hat-trap-cli/main.go b/internal/tc2-hat-trap-cli/main.go index 1607dd5..5978762 100644 --- a/internal/tc2-hat-trap-cli/main.go +++ b/internal/tc2-hat-trap-cli/main.go @@ -1,18 +1,9 @@ package trapcli import ( - "bytes" - "compress/flate" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/json" "errors" "fmt" "os" - "path/filepath" - "strings" - "time" comms "github.com/TheCacophonyProject/tc2-hat-controller/internal/tc2-hat-comms" @@ -29,9 +20,6 @@ var ( ) type Args struct { - Command *Command `arg:"subcommand:command" help:"Send a command."` - Read *Read `arg:"subcommand:read" help:"Read from a variable."` - Write *Write `arg:"subcommand:write" help:"Write to a variable."` Listen *Listen `arg:"subcommand:listen" help:"Continuously listen for messages from the RP2040."` Message *CMDMessage `arg:"subcommand:msg" help:"Send a message to the RP2040."` CopyFile *CopyFile `arg:"subcommand:copy-file" help:"Copy a file to the RP2040."` @@ -62,19 +50,6 @@ type CopyDir struct { Force bool `arg:"--force" help:"Force overwrite of the files."` } -type Command struct { - Command string `arg:"--command,required" help:"The command to run."` -} - -type Read struct { - Variable string `arg:"--variable,required" help:"The variable to read from."` -} - -type Write struct { - Variable string `arg:"--variable,required" help:"The variable to write to."` - Value string `arg:"--value,required" help:"The value to write."` -} - type ReadTime struct{} type WriteTime struct { @@ -89,33 +64,6 @@ var defaultArgs = Args{ BaudRate: 9600, } -const maxRetries = 3 - -func sendMessage(msg comms.Message, port *serialhelper.SerialPort) (*comms.Message, error) { - line := msg.ToUARTLine() - var lastErr error - for i := range maxRetries { - if i > 0 { - log.Warnf("Retrying (%d/%d): %s", i, maxRetries-1, strings.TrimSpace(line)) - } else { - //log.Println("Sending:", strings.TrimSpace(line)) - } - if err := port.Write([]byte(line)); err != nil { - return nil, err - } - select { - case line, ok := <-port.Lines: - if !ok { - return nil, fmt.Errorf("serial port closed while waiting for response") - } - return comms.ParseLine(line) - case <-time.After(5 * time.Second): - lastErr = fmt.Errorf("timeout waiting for response") - } - } - return nil, lastErr -} - func procArgs(input []string) (Args, error) { args := defaultArgs @@ -150,8 +98,7 @@ func Run(inputArgs []string, ver string) error { } defer port.Close() - switch { - case args.Listen != nil: + if args.Listen != nil { log.Info("Listening for messages from RP2040 (Ctrl+C to stop)...") for line := range port.Lines { msg, err := comms.ParseLine(line) @@ -163,182 +110,50 @@ func Run(inputArgs []string, ver string) error { log.Println("Received:", msg) } return nil + } - case args.Command != nil: - data, err := json.Marshal(map[string]string{"command": args.Command.Command}) - if err != nil { - return err - } - return respond(sendMessage(comms.Message{Type: "command", Payload: string(data)}, port)) - - case args.Read != nil: - data, err := json.Marshal(map[string]string{"var": args.Read.Variable}) - if err != nil { - return err - } - return respond(sendMessage(comms.Message{Type: "read", Payload: string(data)}, port)) - - case args.Write != nil: - data, err := json.Marshal(map[string]string{"var": args.Write.Variable, "val": args.Write.Value}) - if err != nil { - return err - } - return respond(sendMessage(comms.Message{Type: "write", Payload: string(data)}, port)) + messenger := comms.NewTrapMessenger(port) + messenger.Start() + switch { case args.Message != nil: - message := comms.Message{ID: args.Message.ID, Type: args.Message.Type, Payload: args.Message.Payload} - return respond(sendMessage(message, port)) + message := comms.Message{Type: args.Message.Type, Payload: args.Message.Payload} + return comms.HandleResponse(messenger.SendMessage(message)) case args.Restart != nil: - return respond(sendMessage(comms.Message{Type: "RESTART"}, port)) + return messenger.Restart() case args.CopyFile != nil: - if err := copyFile(args.CopyFile.Source, args.CopyFile.Dest, port, args.CopyFile.Force); err != nil { + fileUpdated, err := messenger.CopyFile(args.CopyFile.Source, args.CopyFile.Dest, args.CopyFile.Force) + if err != nil { return err } - return commitFiles(port) + if fileUpdated { + log.Info("File updated.") + } else { + log.Info("File is already up to date.") + } + return messenger.CommitFiles() case args.CopyDir != nil: - entries, err := os.ReadDir(args.CopyDir.Source) + fileUpdated, err := messenger.CopyDir(args.CopyDir.Source, args.CopyDir.Dest, args.CopyDir.Force) if err != nil { - return fmt.Errorf("failed to read directory %s: %v", args.CopyDir.Source, err) + return err } - for _, entry := range entries { - if entry.IsDir() { - continue - } - localFile := filepath.Join(args.CopyDir.Source, entry.Name()) - destFile := filepath.Join(args.CopyDir.Dest, entry.Name()) - if err := copyFile(localFile, destFile, port, args.CopyDir.Force); err != nil { - return fmt.Errorf("failed to copy %s: %v", entry.Name(), err) - } + if fileUpdated { + log.Info("Files updated.") + } else { + log.Info("Files are already up to date.") } - return commitFiles(port) + return nil case args.ReadTime != nil: - return respond(sendMessage(comms.Message{Type: "READ_TIME"}, port)) + return messenger.ReadTime() case args.WriteTime != nil: - timeStr := time.Now().UTC().Format(time.DateTime) - if args.WriteTime.Time != "" { - timeStr = args.WriteTime.Time - } - log.Printf("Writing UTC time: '%s'", timeStr) - return respond(sendMessage(comms.Message{Type: "WRITE_TIME", Payload: timeStr}, port)) + return messenger.WriteTime(args.WriteTime.Time) default: return fmt.Errorf("no subcommand given") } } - -func commitFiles(port *serialhelper.SerialPort) error { - log.Println("Committing all .tmp files...") - return respond(sendMessage(comms.Message{Type: "COMMIT"}, port)) -} - -func respond(response *comms.Message, err error) error { - if err != nil { - return err - } - if response.Type == "NACK" { - return fmt.Errorf("NACK response: %s", response.Payload) - } - log.Infof("Response: type=%s, payload=%s", response.Type, response.Payload) - return nil -} - -func copyFile(localFile, destFile string, port *serialhelper.SerialPort, force bool) error { - destBase := filepath.Base(destFile) - compressedBase := destBase + ".ztmp" - tmpBase := destBase + ".tmp" - log.Printf("Uploading '%s' as '%s'", destFile, tmpBase) - - localData, err := os.ReadFile(localFile) - if err != nil { - return fmt.Errorf("failed to read local file %s: %v", localFile, err) - } - - h := sha256.Sum256(localData) - localHash := hex.EncodeToString(h[:])[:10] - - lsResp, err := sendMessage(comms.Message{Type: "LS", Payload: destBase + "," + compressedBase + "," + tmpBase}, port) - if err != nil { - return fmt.Errorf("failed to list files: %v", err) - } - var fileHashes map[string]string - if err := json.Unmarshal([]byte(lsResp.Payload), &fileHashes); err != nil { - return fmt.Errorf("failed to parse LS response: %v", err) - } - - // Check if file or tmp file is already up to date - if fileHashes[destBase] == localHash { - log.Printf("\tFile is already up to date.") - if !force { - return nil - } - log.Println("\tForce flag is set, still uploading.") - } - if fileHashes[tmpBase] == localHash { - log.Printf("\t.tmp file is already up to date.") - if !force { - return nil - } - log.Println("\tForce flag is set, still uploading.") - } - - // Delete old ztmp file if it exists - if _, ok := fileHashes[compressedBase]; ok { - if err := respond(sendMessage(comms.Message{Type: "DELETE", Payload: compressedBase}, port)); err != nil { - return fmt.Errorf("failed to delete temp file: %v", err) - } - } - - var compressed bytes.Buffer - fw, err := flate.NewWriter(&compressed, flate.HuffmanOnly) - if err != nil { - return fmt.Errorf("failed to create compressor: %v", err) - } - if _, err := fw.Write(localData); err != nil { - return fmt.Errorf("failed to compress file: %v", err) - } - if err := fw.Close(); err != nil { - return fmt.Errorf("failed to finalize compression: %v", err) - } - encoded := base64.StdEncoding.EncodeToString(compressed.Bytes()) - log.Infof("\t%d bytes -> %d bytes compressed (%.0f%%)", len(localData), compressed.Len(), float64(compressed.Len())/float64(len(localData))*100) - - const chunkSize = 500 - totalChunks := (len(encoded) + chunkSize - 1) / chunkSize - for i := 0; i < len(encoded); i += chunkSize { - chunkNum := i/chunkSize + 1 - log.Infof("\t%s: %d/%d", filepath.Base(localFile), chunkNum, totalChunks) - chunk, err := json.Marshal([]string{encoded[i:min(i+chunkSize, len(encoded))]}) - if err != nil { - return fmt.Errorf("failed to marshal chunk: %v", err) - } - if err := respond(sendMessage(comms.Message{Type: "WRITE", Payload: compressedBase + "," + string(chunk)}, port)); err != nil { - return fmt.Errorf("failed to write chunk at offset %d: %v", i, err) - } - } - - log.Println("\tDecompressing...") - if err := respond(sendMessage(comms.Message{Type: "DECOMPRESS", Payload: compressedBase + "," + tmpBase}, port)); err != nil { - return fmt.Errorf("failed to decompress file: %v", err) - } - - log.Println("\tVerifying...") - lsResp2, err := sendMessage(comms.Message{Type: "LS", Payload: tmpBase}, port) - if err != nil { - return fmt.Errorf("failed to verify file: %v", err) - } - var fileHashes2 map[string]string - if err := json.Unmarshal([]byte(lsResp2.Payload), &fileHashes2); err != nil { - return fmt.Errorf("failed to parse verify LS response: %v", err) - } - if fileHashes2[tmpBase] != localHash { - return fmt.Errorf("file verification failed: hash mismatch") - } - - log.Printf("\tFile '%s' copied successfully.", tmpBase) - return nil -} From 7478eb05ed8f7180c9abf97c1d0e4c7abcad76e9 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sun, 7 Jun 2026 21:18:27 +1200 Subject: [PATCH 09/10] Change logging levels --- internal/tc2-hat-comms/trap-messenger.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/tc2-hat-comms/trap-messenger.go b/internal/tc2-hat-comms/trap-messenger.go index cffcd48..53422c9 100644 --- a/internal/tc2-hat-comms/trap-messenger.go +++ b/internal/tc2-hat-comms/trap-messenger.go @@ -90,7 +90,7 @@ func (u *TrapMessenger) SendMessage(message Message) (*Message, error) { }() line := message.ToUARTLine() - log.Infof("Message: '%s'", line) + log.Debugf("Message: '%s'", line) if err := u.port.Write([]byte(line)); err != nil { return nil, err @@ -98,7 +98,7 @@ func (u *TrapMessenger) SendMessage(message Message) (*Message, error) { select { case response := <-ch: - log.Println("Response:", response) + log.Debug("Response:", response) return response, nil case <-time.After(5 * time.Second): return nil, fmt.Errorf("timeout waiting for response to message ID %d", id) From 74a3bdbc319b74cdb6debc06b562ad6664f970b9 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sun, 7 Jun 2026 21:32:10 +1200 Subject: [PATCH 10/10] Download trap firmware when making a release --- .github/workflows/goreleaser.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml index 2ea7ab8..38d6c7d 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -5,6 +5,9 @@ env: ATTINY_MINOR: 0 ATTINY_PATCH: 10 RELEASE_DOWNLOAD: https://github.com/TheCacophonyProject/attiny1616/releases/download/v${ATTINY_MAJOR}.${ATTINY_MINOR}.${ATTINY_PATCH} + SPOOL_PY_VERSION: 0.0.1 + SPOOL_MPY_VERSION: 1.28.0rc0.post2 + SPOOL_PY_DOWNLOAD: https://github.com/TheCacophonyProject/spool-py/releases/download/v${SPOOL_PY_VERSION}/spool-mpy-${SPOOL_MPY_VERSION}.zip on: push: @@ -33,6 +36,13 @@ jobs: wget -O _release/attiny-firmware.hex ${{ env.RELEASE_DOWNLOAD }}/firmware.hex wget -O _release/attiny-firmware.hex.sha256 ${{ env.RELEASE_DOWNLOAD }}/firmware.hex.sha256 + - name: Download spool-py MicroPython files + run: | + rm -rf _release/mpy + wget -O spool-mpy.zip ${{ env.SPOOL_PY_DOWNLOAD }} + unzip spool-mpy.zip -d _release/mpy/ + rm spool-mpy.zip + - name: Set ATTINY_HASH run: echo "ATTINY_HASH=$(cut -d ' ' -f 1 < _release/attiny-firmware.hex.sha256)" >> $GITHUB_ENV