diff --git a/.gitignore b/.gitignore index 3b735ec..2e7d4fa 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ # Go workspace file go.work +build diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4495618 --- /dev/null +++ b/Makefile @@ -0,0 +1,35 @@ +# VERSION := $(shell git describe --tags) +# LD_FLAGS := -ldflags="-X 'main.Version=$(VERSION)'" + +SRC := ./cmd/lady +BIN := build/fpvc-lady + +clean: + @rm -rf build + @mkdir -p build + +build-darwin-amd64: + GOOS=darwin GOARCH=amd64 go build $(LD_FLAGS) -o build/fpvc-lady-darwin-amd64 $(SRC) + +build-windows-386: + GOOS=windows GOARCH=386 go build $(LD_FLAGS) -o build/fpvc-lady-windows-386.exe $(SRC) + +build-windows-amd64: + GOOS=windows GOARCH=amd64 go build $(LD_FLAGS) -o build/fpvc-lady-windows-amd64.exe $(SRC) + +build-linux-386: + GOOS=linux GOARCH=386 go build $(LD_FLAGS) -o build/fpvc-lady-linux-386 $(SRC) + +build-linux-amd64: + GOOS=linux GOARCH=amd64 go build $(LD_FLAGS) -o build/fpvc-lady-linux-amd64 $(SRC) + +build-linux-arm: + GOOS=linux GOARCH=arm go build $(LD_FLAGS) -o build/fpvc-lady-linux-arm $(SRC) + +build-linux-arm64: + GOOS=linux GOARCH=arm64 go build $(LD_FLAGS) -o build/fpvc-lady-linux-arm64 $(SRC) + +build: clean build-darwin-amd64 build-windows-386 build-windows-amd64 build-linux-386 build-linux-amd64 build-linux-arm build-linux-arm64 + +run: + go run $(SRC) \ No newline at end of file diff --git a/cmd/lady/comment.go b/cmd/lady/comment.go new file mode 100644 index 0000000..90a4437 --- /dev/null +++ b/cmd/lady/comment.go @@ -0,0 +1,90 @@ +package main + +import ( + "fmt" + "io" + "os" + "time" + + "github.com/urfave/cli/v2" + "github.com/ysoldak/fpvc-lady/internal/csp" + "github.com/ysoldak/fpvc-lady/internal/game" + "github.com/ysoldak/fpvc-lady/internal/tts" + "github.com/ysoldak/fpvc-lady/internal/utils" +) + +func commentAction(cc *cli.Context) (err error) { + + // TTS + speech := cc.String(flagSpeech) + speakerChan := make(chan string, 100) + ttsEngine := tts.NewByName(speech, speakerChan) + go ttsEngine.Run() + + // Serial + serial := io.ReadWriter(os.Stdin) + port := cc.String(flagPort) + if port != "none" { + serial, err = utils.NewSerial(port) + if err != nil { + return err + } + } + + // CSP + eventsChan := make(chan interface{}, 100) + cspEngine := csp.New(serial, eventsChan) + go cspEngine.Run() + + // Game + g := game.NewGame() + + // Temporary for testing + if port == "none" { + go func() { + for { + time.Sleep(5 * time.Second) + eventsChan <- csp.Hit{ + PlayerID: 0xA1, + Lives: 10, + } + time.Sleep(100 * time.Millisecond) + eventsChan <- csp.Claim{ + PlayerID: 0xB1, + Power: 3, + } + } + }() + } + + // Main loop + speakerChan <- "The lady is ready." + for { + event := <-eventsChan + switch event := event.(type) { + case csp.Beacon: + player, new := g.Beacon(event) + if new { + speakerChan <- fmt.Sprintf("%s registered", player.Name) + } + // fmt.Printf("%v Beacon: %X %s %s\n", time.Now(), event.PlayerID, event.Name, event.Description) + case csp.Hit: + g.Hit(event) + // println("Hit: ", event.PlayerID, event.Lives) + case csp.Claim: + victim, ok := g.Claim(event) + if !ok { + continue + } + attacker, _ := g.Player(event.PlayerID) + speakerChan <- fmt.Sprintf("%s was hit by %s. %d lives left.", victim.Name, attacker.Name, victim.Lives) + // println("Claim: ", event.PlayerID, event.Power) + } + println() + println() + println() + for _, line := range g.Table() { + println(line) + } + } +} diff --git a/cmd/lady/flags.go b/cmd/lady/flags.go new file mode 100644 index 0000000..06f8247 --- /dev/null +++ b/cmd/lady/flags.go @@ -0,0 +1,29 @@ +package main + +import ( + "github.com/urfave/cli/v2" +) + +const ( + flagPort = "port" + flagSpeech = "speech" +) + +func getFlags() []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: flagPort, + Usage: "Port name", + EnvVars: []string{"PORT"}, + Required: false, + Value: "", + }, + &cli.StringFlag{ + Name: flagSpeech, + Usage: "Speech command: [system], google, none or any other command to convert text to speech.", + EnvVars: []string{"SPEECH"}, + Required: false, + Value: "system", + }, + } +} diff --git a/cmd/lady/main.go b/cmd/lady/main.go new file mode 100644 index 0000000..959c5e9 --- /dev/null +++ b/cmd/lady/main.go @@ -0,0 +1,20 @@ +package main + +import ( + "log" + "os" + + "github.com/urfave/cli/v2" +) + +func main() { + app := cli.NewApp() + app.Name = "fpvc-lady" + + app.Flags = getFlags() + app.Action = commentAction + + if err := app.Run(os.Args); err != nil { + log.Fatalf("error: %s\n", err.Error()) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8fa3db4 --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module github.com/ysoldak/fpvc-lady + +go 1.22.4 + +require ( + github.com/hegedustibor/htgo-tts v0.0.0-20211106065519-4b33b08f698f + github.com/urfave/cli/v2 v2.27.2 + go.bug.st/serial v1.6.2 +) + +require ( + github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect + github.com/creack/goselect v0.1.2 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect + golang.org/x/sys v0.21.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b5dac99 --- /dev/null +++ b/go.sum @@ -0,0 +1,24 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0= +github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/hegedustibor/htgo-tts v0.0.0-20211106065519-4b33b08f698f h1:9hj9NB/nSMz1AF/uw1J51gNmbfInP8oQ446C/50o1gE= +github.com/hegedustibor/htgo-tts v0.0.0-20211106065519-4b33b08f698f/go.mod h1:Uqnv3qFrs2WtaeO2/+PQ35x4HdD4u2ZAX/PcQEEP5VY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/urfave/cli/v2 v2.27.2 h1:6e0H+AkS+zDckwPCUrZkKX38mRaau4nL2uipkJpbkcI= +github.com/urfave/cli/v2 v2.27.2/go.mod h1:g0+79LmHHATl7DAcHO99smiR/T7uGLw84w8Y42x+4eM= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +go.bug.st/serial v1.6.2 h1:kn9LRX3sdm+WxWKufMlIRndwGfPWsH1/9lCWXQCasq8= +go.bug.st/serial v1.6.2/go.mod h1:UABfsluHAiaNI+La2iESysd9Vetq7VRdpxvjx7CmmOE= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +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/csp/beacon.go b/internal/csp/beacon.go new file mode 100644 index 0000000..ef8ab24 --- /dev/null +++ b/internal/csp/beacon.go @@ -0,0 +1,15 @@ +package csp + +type Beacon struct { + PlayerID byte + Name string + Description string +} + +func NewBeacon(data []byte) Beacon { + return Beacon{ + PlayerID: data[0], + Name: string(data[1:11]), + Description: string(data[11:31]), + } +} diff --git a/internal/csp/claim.go b/internal/csp/claim.go new file mode 100644 index 0000000..a5de42c --- /dev/null +++ b/internal/csp/claim.go @@ -0,0 +1,13 @@ +package csp + +type Claim struct { + PlayerID byte + Power byte +} + +func NewClaim(data []byte) Claim { + return Claim{ + PlayerID: data[0], + Power: data[1], + } +} diff --git a/internal/csp/csp.go b/internal/csp/csp.go new file mode 100644 index 0000000..d07d11b --- /dev/null +++ b/internal/csp/csp.go @@ -0,0 +1,96 @@ +package csp + +import "io" + +const ( + COMMAND_BEACON byte = 0x71 + + COMMAND_HIT byte = 0x82 + COMMAND_CLAIM byte = 0x84 +) + +const ( + // States + STATE_IDLE byte = iota + STATE_HEADER + STATE_LENGTH + STATE_COMMAND + STATE_DATA + STATE_CHECKSUM +) + +type CSP struct { + serial io.ReadWriter + events chan interface{} + + state byte + message Message +} + +func New(serial io.ReadWriter, events chan interface{}) *CSP { + return &CSP{ + serial: serial, + events: events, + } +} + +func (csp *CSP) Run() { + buf := make([]byte, 1000) + for { + n, err := csp.serial.Read(buf) + if err != nil { + panic(err) + } + if n == 0 { + continue + } + for i := 0; i < n; i++ { + b := buf[i] + switch csp.state { + case STATE_IDLE: + if b == '$' { + csp.message.header[0] = b + csp.state = STATE_HEADER + } + case STATE_HEADER: + if b == 'C' { + csp.message.header[1] = b + csp.state = STATE_LENGTH + } else { + csp.state = STATE_IDLE + } + case STATE_LENGTH: + csp.message.length = b + csp.message.checksum = b + csp.state = STATE_COMMAND + case STATE_COMMAND: + csp.message.command = b + csp.message.checksum ^= b + csp.state = STATE_DATA + case STATE_DATA: + csp.message.data = append(csp.message.data, b) + csp.message.checksum ^= b + if len(csp.message.data) == int(csp.message.length) { + csp.state = STATE_CHECKSUM + } + case STATE_CHECKSUM: + if csp.message.checksum == b { + csp.emitEvent() + } + csp.message = Message{} + csp.state = STATE_IDLE + } + } + } +} + +func (csp *CSP) emitEvent() { + switch csp.message.command { + case COMMAND_BEACON: + csp.events <- NewBeacon(csp.message.data) + case COMMAND_HIT: + csp.events <- NewHit(csp.message.data) + case COMMAND_CLAIM: + csp.events <- NewClaim(csp.message.data) + } +} diff --git a/internal/csp/hit.go b/internal/csp/hit.go new file mode 100644 index 0000000..94ce111 --- /dev/null +++ b/internal/csp/hit.go @@ -0,0 +1,13 @@ +package csp + +type Hit struct { + PlayerID byte + Lives byte +} + +func NewHit(data []byte) Hit { + return Hit{ + PlayerID: data[0], + Lives: data[1], + } +} diff --git a/internal/csp/message.go b/internal/csp/message.go new file mode 100644 index 0000000..855da4d --- /dev/null +++ b/internal/csp/message.go @@ -0,0 +1,9 @@ +package csp + +type Message struct { + header [2]byte // '$' + 'C' + length byte // Length of the data + command byte // 0x82 = Claim, 0x83 = Hit + data []byte // Data + checksum byte // XOR of all bytes from length to the end of data +} diff --git a/internal/game/game.go b/internal/game/game.go new file mode 100644 index 0000000..bb0f95c --- /dev/null +++ b/internal/game/game.go @@ -0,0 +1,81 @@ +package game + +import ( + "fmt" + "time" + + "github.com/ysoldak/fpvc-lady/internal/csp" +) + +type Game struct { + Players []*Player + + Victim *Player +} + +func NewGame() Game { + return Game{ + Players: []*Player{}, + } +} + +func (g *Game) Beacon(event csp.Beacon) (player *Player, new bool) { + player, new = g.Player(event.PlayerID) + player.Name = event.Name + player.Description = event.Description + player.Updated = time.Now() + return +} + +func (g *Game) Hit(event csp.Hit) { + victim, _ := g.Player(event.PlayerID) + victim.Lives = event.Lives + g.Victim = victim +} + +func (g *Game) Claim(event csp.Claim) (victim *Player, ok bool) { + attacker, _ := g.Player(event.PlayerID) + if attacker == nil { + attacker = &Player{ + ID: event.PlayerID, + Name: fmt.Sprintf("%X", event.PlayerID), + Lives: 255, + } + g.Players = append(g.Players, attacker) + } + victim = g.Victim + if g.Victim != nil { + g.Victim = nil + attacker.Kills++ + victim.Deaths++ + victim.Lives-- + } + return victim, victim != nil +} + +func (g *Game) Player(id byte) (player *Player, isNew bool) { + for _, p := range g.Players { + if p.ID == id { + return p, false + } + } + player = &Player{ + ID: id, + Name: fmt.Sprintf("%X", id), + Description: "Unknown", + Lives: 255, + } + g.Players = append(g.Players, player) + return player, true +} + +func (g *Game) Table() []string { + table := []string{} + table = append(table, " ID | Name | Description | Updated || Kills | Deaths | Lives ") + table = append(table, "--- | ---------- | ------------------ | ------------ || ----- | ------ | ------") + for _, p := range g.Players { + updated := p.Updated.Format("15:04:05.000") + table = append(table, fmt.Sprintf(" %X | %-10s | %-20s | %s || %5d | %6d | %5d", p.ID, p.Name, p.Description, updated, p.Kills, p.Deaths, p.Lives)) + } + return table +} diff --git a/internal/game/player.go b/internal/game/player.go new file mode 100644 index 0000000..3a8e909 --- /dev/null +++ b/internal/game/player.go @@ -0,0 +1,14 @@ +package game + +import "time" + +type Player struct { + ID byte + Name string + Description string + Value byte + Lives byte + Kills byte + Deaths byte + Updated time.Time +} diff --git a/internal/tts/custom.go b/internal/tts/custom.go new file mode 100644 index 0000000..427db02 --- /dev/null +++ b/internal/tts/custom.go @@ -0,0 +1,19 @@ +package tts + +import ( + "log" + "os/exec" +) + +type Custom struct { + executable string + parameters []string +} + +func (tts *Custom) Speak(phrase string) { + args := append(tts.parameters, phrase) + cmd := exec.Command(tts.executable, args...) + if err := cmd.Run(); err != nil { + log.Fatal(err) + } +} diff --git a/internal/tts/google.go b/internal/tts/google.go new file mode 100644 index 0000000..5624a0a --- /dev/null +++ b/internal/tts/google.go @@ -0,0 +1,45 @@ +package tts + +import ( + "os" + "os/exec" + + htgotts "github.com/hegedustibor/htgo-tts" + "github.com/hegedustibor/htgo-tts/voices" +) + +type Google struct { + htgo htgotts.Speech +} + +func (tts *Google) Speak(phrase string) { + tts.htgo.Speak(phrase) +} + +func NewGoogle() *Google { + if commandExists("mplayer") { + return &Google{ + htgo: htgotts.Speech{Folder: os.TempDir(), Language: voices.English}, + } + } + if commandExists("ffplay") { + return &Google{ + htgo: htgotts.Speech{Folder: os.TempDir(), Language: voices.English, Handler: &FFPlay{}}, + } + } + println("Voice Error. For 'google' tts to work, either mplayer or ffmpeg (ffplay) must be installed") + os.Exit(1) + return nil +} + +type FFPlay struct{} + +func (ffp *FFPlay) Play(fileName string) error { + cmd := exec.Command("ffplay", "-nodisp", "-autoexit", fileName) + return cmd.Run() +} + +func commandExists(cmd string) bool { + _, err := exec.LookPath(cmd) + return err == nil +} diff --git a/internal/tts/none.go b/internal/tts/none.go new file mode 100644 index 0000000..459646f --- /dev/null +++ b/internal/tts/none.go @@ -0,0 +1,10 @@ +package tts + +import "time" + +type None struct { +} + +func (tts *None) Speak(phrase string) { + time.Sleep(time.Second) +} diff --git a/internal/tts/system.go b/internal/tts/system.go new file mode 100644 index 0000000..a41db46 --- /dev/null +++ b/internal/tts/system.go @@ -0,0 +1,17 @@ +package tts + +import ( + "runtime" +) + +func NewSystem() TtsBackend { + switch { + case runtime.GOOS == "windows": + return &Windows{} + case runtime.GOOS == "darwin": + return &Custom{executable: "say"} + case runtime.GOOS == "linux": + return &None{} + } + return nil +} diff --git a/internal/tts/tts.go b/internal/tts/tts.go new file mode 100644 index 0000000..52a006f --- /dev/null +++ b/internal/tts/tts.go @@ -0,0 +1,54 @@ +package tts + +import "strings" + +const ( + BackendNone = "none" + BackendGoogle = "google" + BackendSystem = "system" +) + +type TtsBackend interface { + Speak(phrase string) +} + +type Tts struct { + backend TtsBackend + phrases chan string +} + +func New(backend TtsBackend, phrases chan string) *Tts { + return &Tts{ + backend: backend, + phrases: phrases, + } +} + +func NewByName(backendName string, phrases chan string) *Tts { + var backend TtsBackend + switch backendName { + case BackendNone: + backend = &None{} + case BackendGoogle: + backend = NewGoogle() + case BackendSystem: + backend = NewSystem() + default: + exec := backendName + params := []string{} + if strings.Contains(exec, " ") { + parts := strings.Split(exec, " ") + exec = parts[0] + params = parts[1:] + } + backend = &Custom{executable: exec, parameters: params} + } + return New(backend, phrases) +} + +func (tts *Tts) Run() { + for { + phrase := <-tts.phrases + tts.backend.Speak(phrase) + } +} diff --git a/internal/tts/win.go b/internal/tts/win.go new file mode 100644 index 0000000..b624d53 --- /dev/null +++ b/internal/tts/win.go @@ -0,0 +1,23 @@ +package tts + +import ( + "fmt" + "log" + "os/exec" + "strings" +) + +type Windows struct { +} + +func (tts *Windows) Speak(phrase string) { + // PowerShell interprets special characters as parts of commands, so we have to escape them before executing + replacer := strings.NewReplacer("'", "''", "’", "'’") + formattedPhrase := replacer.Replace(phrase) + + command := fmt.Sprintf("Add-Type -AssemblyName System.Speech; (New-Object System.Speech.Synthesis.SpeechSynthesizer).Speak('%s');", formattedPhrase) + _, err := exec.Command("powershell", "-NoProfile", command).CombinedOutput() + if err != nil { + log.Fatal(err) + } +} diff --git a/internal/utils/serial.go b/internal/utils/serial.go new file mode 100644 index 0000000..0a13ad9 --- /dev/null +++ b/internal/utils/serial.go @@ -0,0 +1,74 @@ +package utils + +import ( + "errors" + "io" + "path/filepath" + "runtime" + "strings" + + "go.bug.st/serial" +) + +func NewSerial(candidate string) (io.ReadWriter, error) { + port, err := findSerialPort(candidate) + if err != nil { + return nil, err + } + serial, err := serial.Open(port, &serial.Mode{ + BaudRate: 9600, + }) + if err != nil { + return nil, err + } + return serial, nil +} + +func findSerialPort(candidate string) (port string, err error) { + ports := []string{} + switch runtime.GOOS { + case "darwin": + ports, err = searchPaths("/dev/cu.usb*") + case "linux": + ports, err = searchPaths("/dev/ttyACM*", "/dev/ttyUSB*") + case "windows": + ports, err = serial.GetPortsList() + } + + if err != nil { + return "", err + } else if ports == nil { + return "", errors.New("unable to locate a serial port") + } else if len(ports) == 0 { + return "", errors.New("no serial ports available") + } + + if candidate == "" { + if len(ports) == 1 { + return ports[0], nil + } else { + return "", errors.New("multiple serial ports available - use -port flag, available ports are " + strings.Join(ports, ", ")) + } + } + + for _, p := range ports { + if p == candidate { + return p, nil + } + } + + return "", errors.New("port you specified '" + candidate + "' does not exist, available ports are " + strings.Join(ports, ", ")) +} + +// searchPaths supports several path patterns, wraps filepath.Glob that can do only one +func searchPaths(patterns ...string) ([]string, error) { + result := []string{} + for _, pattern := range patterns { + ports, err := filepath.Glob(pattern) + if err != nil { + return nil, err + } + result = append(result, ports...) + } + return result, nil +}