Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cmd/teslausb/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/teslausb-go/teslausb/internal/state"
"github.com/teslausb-go/teslausb/internal/system"
"github.com/teslausb-go/teslausb/internal/web"
"github.com/teslausb-go/teslausb/internal/wire"
)

var version = "dev"
Expand Down Expand Up @@ -72,7 +73,7 @@ func main() {
go monitor.RunWiFiMonitor(ctx)

// Create state machine
machine := state.New()
machine := state.New(wire.NewDeps())

// Start web server
srv := web.NewServer(machine, version, *configPath)
Expand Down
4 changes: 3 additions & 1 deletion internal/archive/archive.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import (

const ArchiveMount = "/mnt/archive"

const tcpDialTimeout = 5 * time.Second

// IsReachable checks if the archive server is reachable via TCP.
func IsReachable() bool {
cfg := config.Get()
Expand All @@ -32,7 +34,7 @@ func tcpReachable(host, port string) bool {
if host == "" {
return false
}
conn, err := net.DialTimeout("tcp", host+":"+port, 5*time.Second)
conn, err := net.DialTimeout("tcp", host+":"+port, tcpDialTimeout)
if err != nil {
return false
}
Expand Down
9 changes: 8 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,17 @@ func Load(path string) (*Config, error) {
return &cfg, nil
}

// Get returns a shallow copy of the current config.
// Safe because Config contains only value-type fields.
// If slice/map fields are added, switch to a deep copy.
func Get() *Config {
mu.RLock()
defer mu.RUnlock()
return current
if current == nil {
return nil
}
cp := *current
return &cp
}

func Save(path string, cfg *Config) error {
Expand Down
21 changes: 21 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,24 @@ func TestSaveAndReload(t *testing.T) {
t.Errorf("expected 10.0.0.1, got %s", loaded.NFS.Server)
}
}

func TestGetReturnsCopy(t *testing.T) {
// Load a config so current is set
tmpFile := filepath.Join(t.TempDir(), "test.yaml")
os.WriteFile(tmpFile, []byte("nfs:\n server: original\n"), 0644)
Load(tmpFile)

cfg1 := Get()
if cfg1 == nil {
t.Fatal("expected non-nil config")
}
cfg1.NFS.Server = "mutated"

cfg2 := Get()
if cfg2.NFS.Server == "mutated" {
t.Error("Get() should return a copy; mutation leaked through")
}
if cfg2.NFS.Server != "original" {
t.Errorf("expected 'original', got %q", cfg2.NFS.Server)
}
}
12 changes: 9 additions & 3 deletions internal/disk/disk.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ const (
MountPoint = "/mnt/cam"
)

const (
diskReserveBytes = 500 * 1024 * 1024 // 500MB headroom for backing partition
minDiskSize = 1024 * 1024 * 1024 // 1GB minimum cam disk
truncatedClipThreshold = int64(100_000) // clips smaller than this are truncated
)

func Exists() bool {
_, err := os.Stat(BackingFile)
return err == nil
Expand All @@ -34,9 +40,9 @@ func Create() error {
return fmt.Errorf("statfs: %w", err)
}
available := int64(stat.Bavail) * int64(stat.Bsize)
reserve := int64(500 * 1024 * 1024) // 500MB headroom
reserve := int64(diskReserveBytes)
size := available - reserve
if size < 1024*1024*1024 { // minimum 1GB
if size < minDiskSize {
return fmt.Errorf("not enough space: %d bytes available", available)
}

Expand Down Expand Up @@ -157,7 +163,7 @@ func CleanArtifacts() {
if err != nil || info.IsDir() {
return nil
}
if strings.HasSuffix(strings.ToLower(path), ".mp4") && info.Size() < 100_000 {
if strings.HasSuffix(strings.ToLower(path), ".mp4") && info.Size() < truncatedClipThreshold {
os.Remove(path)
log.Printf("cleaned truncated: %s (%d bytes)", filepath.Base(path), info.Size())
}
Expand Down
14 changes: 10 additions & 4 deletions internal/gadget/idle.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ import (
"time"
)

const (
idleThresholdBytes = int64(500_000) // bytes/sec below which USB is "idle"
idleConsecutiveRequired = 5 // consecutive idle checks needed
idleTimeoutSeconds = 90 // max seconds to wait for idle
)

func findMassStoragePID() (int, error) {
entries, _ := os.ReadDir("/proc")
for _, e := range entries {
Expand Down Expand Up @@ -53,10 +59,10 @@ func WaitForIdle() error {

prevBytes := int64(-1)
idleCount := 0
threshold := int64(500_000)
threshold := idleThresholdBytes

log.Println("waiting for USB write idle...")
for i := 0; i < 90; i++ {
for i := 0; i < idleTimeoutSeconds; i++ {
time.Sleep(1 * time.Second)
written, err := readWriteBytes(pid)
if err != nil {
Expand All @@ -71,13 +77,13 @@ func WaitForIdle() error {

if delta < threshold {
idleCount++
if idleCount >= 5 {
if idleCount >= idleConsecutiveRequired {
log.Println("USB write idle detected")
return nil
}
} else {
idleCount = 0
}
}
return fmt.Errorf("timeout waiting for USB idle after 90 seconds")
return fmt.Errorf("timeout waiting for USB idle after %d seconds", idleTimeoutSeconds)
}
59 changes: 59 additions & 0 deletions internal/state/deps.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package state

import (
"context"

"github.com/teslausb-go/teslausb/internal/webhook"
)

// DiskOps abstracts disk image operations.
type DiskOps interface {
Exists() bool
Create() error
Mount() error
Unmount() error
CleanArtifacts()
BackingFilePath() string
}

// GadgetOps abstracts USB gadget lifecycle.
type GadgetOps interface {
Enable(backingFile string) error
Disable() error
WaitForIdle() error
}

// ArchiveOps abstracts clip archiving operations.
type ArchiveOps interface {
IsReachable() bool
MountArchive() error
UnmountArchive()
ArchiveClips(ctx context.Context) (clips int, bytes int64, err error)
ManageFreeSpace()
}

// SystemOps abstracts system-level operations (LED, time sync).
type SystemOps interface {
SetLED(mode string)
SyncTime()
}

// KeepAwaker abstracts vehicle keep-awake signaling.
type KeepAwaker interface {
Send(ctx context.Context, command string)
}

// Notifier abstracts event notifications.
type Notifier interface {
Send(ctx context.Context, event webhook.Event)
}

// Deps bundles all external dependencies for the state machine.
type Deps struct {
Disk DiskOps
Gadget GadgetOps
Archive ArchiveOps
System SystemOps
KeepAwake KeepAwaker
Notify Notifier
}
Loading