From 6436c86e0c53d9fbd83d883f76cbd8a267b6428e Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 7 Feb 2022 20:50:53 +0200 Subject: Implement initial PoC for Kodi --- .gitignore | 1 + LICENSE | 21 +++++ README.md | 29 +++++++ cmd/magic4linux/main.go | 171 ++++++++++++++++++++++++++++++++++++++++ go.mod | 7 ++ go.sum | 2 + m4p/client.go | 203 ++++++++++++++++++++++++++++++++++++++++++++++++ m4p/discover.go | 74 ++++++++++++++++++ m4p/m4p.go | 46 +++++++++++ m4p/message.go | 91 ++++++++++++++++++++++ 10 files changed, 645 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cmd/magic4linux/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 m4p/client.go create mode 100644 m4p/discover.go create mode 100644 m4p/m4p.go create mode 100644 m4p/message.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..05eba63 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +cmd/magic4linux/magic4linux diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e314b3e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Mathias Fredriksson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0ba6fa6 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# magic4linux + +Allows you to use the magic remote on your webOS LG TV as a keyboard/mouse for your ~~PC~~ Linux machine. + +This is a Linux implementation of the [Wouterdek/magic4pc](https://github.com/Wouterdek/magic4pc) client. + +A virtual keyboard and mouse is created via the `/dev/uinput` interface, as provided by the [bendahl/uinput](https://github.com/bendahl/uinput) library. For non-root usage, please add udev rules as instructed in the [`uinput`](https://github.com/bendahl/uinput#uinput-----) documentation. + +## Installation + +```shell +go install github.com/mafredri/magic4linux/cmd/magic4linux +``` + +## Usage + +There are no options yet. + +```shell +magic4linux +``` + +## Building for other platforms + +```shell +git clone https://github.com/mafredri/magic4linux +cd magic4linux/cmd/magic4linux +GOOS=linux GOARCH=arm64 go build +``` diff --git a/cmd/magic4linux/main.go b/cmd/magic4linux/main.go new file mode 100644 index 0000000..0275d64 --- /dev/null +++ b/cmd/magic4linux/main.go @@ -0,0 +1,171 @@ +package main + +import ( + "bytes" + "context" + "encoding/binary" + "fmt" + "log" + "os" + "os/signal" + "syscall" + + "github.com/bendahl/uinput" + + "github.com/mafredri/magic4linux/m4p" +) + +const ( + broadcastPort = 42830 + subscriptionPort = 42831 +) + +func main() { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + if err := run(ctx); err != nil { + panic(err) + } +} + +func run(ctx context.Context) error { + kbd, err := uinput.CreateKeyboard("/dev/uinput", []byte("magic4linux-keyboard")) + if err != nil { + return err + } + defer kbd.Close() + + tp, err := uinput.CreateTouchPad("/dev/uinput", []byte("magic4linux-touchpad"), 0, 1920, 0, 1080) + if err != nil { + return err + } + defer tp.Close() + + d, err := m4p.NewDiscoverer(broadcastPort) + if err != nil { + return err + } + defer d.Close() + + for { + select { + case <-ctx.Done(): + return nil + + case dev := <-d.NextDevice(): + err = connect(ctx, dev, kbd, tp) + if err != nil { + log.Printf("connect: %v", err) + } + } + } +} + +func connect(ctx context.Context, dev m4p.DeviceInfo, kbd uinput.Keyboard, tp uinput.TouchPad) error { + addr := fmt.Sprintf("%s:%d", dev.IPAddr, dev.Port) + log.Printf("connect: connecting to: %s", addr) + + client, err := m4p.Dial(ctx, addr) + if err != nil { + return err + } + defer client.Close() + + for { + m, err := client.Recv(ctx) + if err != nil { + return err + } + + switch m.Type { + case m4p.InputMessage: + log.Printf("connect: got %s: %v", m.Type, m.Input) + + // PoC Kodi keyboard mapping. + key := m.Input.Parameters.KeyCode + switch key { + case m4p.KeyWheelPressed: + key = uinput.KeyEnter + case m4p.KeyChannelUp: + key = uinput.KeyPageup + case m4p.KeyChannelDown: + key = uinput.KeyPagedown + case m4p.KeyLeft: + key = uinput.KeyLeft + case m4p.KeyUp: + key = uinput.KeyUp + case m4p.KeyRight: + key = uinput.KeyRight + case m4p.KeyDown: + key = uinput.KeyDown + case m4p.Key0: + key = uinput.Key0 + case m4p.Key1: + key = uinput.Key1 + case m4p.Key2: + key = uinput.Key1 + case m4p.Key3: + key = uinput.Key1 + case m4p.Key4: + key = uinput.Key1 + case m4p.Key5: + key = uinput.Key1 + case m4p.Key6: + key = uinput.Key1 + case m4p.Key7: + key = uinput.Key1 + case m4p.Key8: + key = uinput.Key1 + case m4p.Key9: + key = uinput.Key1 + case m4p.KeyRed: + key = uinput.KeyStop + case m4p.KeyGreen: + key = uinput.KeyPlaypause + case m4p.KeyYellow: + key = uinput.KeyZ + case m4p.KeyBlue: + key = uinput.KeyC + case m4p.KeyBack: + key = uinput.KeyBackspace + } + + if m.Input.Parameters.IsDown { + kbd.KeyDown(key) + } else { + kbd.KeyUp(key) + } + case m4p.RemoteUpdateMessage: + // log.Printf("connect: got %s: %s", m.Type, hex.EncodeToString(m.RemoteUpdate.Payload)) + + r := bytes.NewReader(m.RemoteUpdate.Payload) + var returnValue, deviceID uint8 + var coordinate [2]int32 + var gyroscope, acceleration [3]float32 + var quaternion [4]float32 + for _, fn := range []func() error{ + func() error { return binary.Read(r, binary.LittleEndian, &returnValue) }, + func() error { return binary.Read(r, binary.LittleEndian, &deviceID) }, + func() error { return binary.Read(r, binary.LittleEndian, coordinate[:]) }, + func() error { return binary.Read(r, binary.LittleEndian, gyroscope[:]) }, + func() error { return binary.Read(r, binary.LittleEndian, acceleration[:]) }, + func() error { return binary.Read(r, binary.LittleEndian, quaternion[:]) }, + } { + if err := fn(); err != nil { + log.Printf("connect: %s decode failed: %v", m.Type, err) + break + } + } + + x := coordinate[0] + y := coordinate[1] + fmt.Println("Move mouse", x, y) + tp.MoveTo(x, y) + + // log.Printf("connect: %d %d %#v %#v %#v %#v", returnValue, deviceID, coordinate, gyroscope, acceleration, quaternion) + + default: + } + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..90ab88a --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module github.com/mafredri/magic4linux + +go 1.17 + +replace github.com/bendahl/uinput v1.5.0 => github.com/mafredri/uinput v1.5.1-0.20220207175943-22c283e0bb09 + +require github.com/bendahl/uinput v1.5.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..aa3accb --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/mafredri/uinput v1.5.1-0.20220207175943-22c283e0bb09 h1:FzGMDvWmcWhrKvPhOBMymBNz7/YOomRrIpiOn2N5rYA= +github.com/mafredri/uinput v1.5.1-0.20220207175943-22c283e0bb09/go.mod h1:Np7w3DINc9wB83p12fTAM3DPPhFnAKP0WTXRqCQJ6Z8= diff --git a/m4p/client.go b/m4p/client.go new file mode 100644 index 0000000..526e6c2 --- /dev/null +++ b/m4p/client.go @@ -0,0 +1,203 @@ +package m4p + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "log" + "net" + "time" +) + +// Client represents an active connection to the magic4pc server. +type Client struct { + ctx context.Context + cancel context.CancelFunc + conn net.Conn + opts dialOptions + serverKeepalive chan struct{} + recvBuf chan Message +} + +type dialOptions struct { + updateFrequency int + filters []string +} + +// DialOption sets options for dial. +type DialOption func(*dialOptions) + +// WithUpdateFrequency sets the RemoteUpdate frequency. +func WithUpdateFrequency(f int) func(*dialOptions) { + return func(o *dialOptions) { + o.updateFrequency = f + } +} + +// WithFilters specifies the filters used for RemoteUpdate. +func WithFilters(filters ...string) func(*dialOptions) { + return func(o *dialOptions) { + o.filters = filters + } +} + +// Dial connects to a magic4pc server running in webOS. +func Dial(ctx context.Context, addr string, opts ...DialOption) (*Client, error) { + o := dialOptions{ + updateFrequency: 250, + filters: DefaultFilters, + } + for _, opt := range opts { + opt(&o) + } + + d := &net.Dialer{ + Timeout: 5 * time.Second, + } + conn, err := d.DialContext(ctx, "udp4", addr) + if err != nil { + return nil, err + } + + ctx, cancel := context.WithCancel(context.Background()) + c := &Client{ + ctx: ctx, + cancel: cancel, + conn: conn, + opts: o, + serverKeepalive: make(chan struct{}, 1), + recvBuf: make(chan Message, 10), // Buffer up to 10 messages after which we block. + } + + // Register our client with the server. + m := NewMessage(SubSensorMessage) + m.Register = &Register{ + UpdateFrequency: c.opts.updateFrequency, + Filter: c.opts.filters, + } + + err = c.Send(m) + if err != nil { + c.Close() + return nil, fmt.Errorf("register failed: %w", err) + } + + // Tell the server that we're alive and well. + go c.keepalive() + go c.recv() + + return c, nil +} + +func (c *Client) recv() { + var buf [1024]byte +recvLoop: + for { + select { + case <-c.ctx.Done(): + return + default: + } + + n, err := c.conn.Read(buf[:]) + if err != nil { + log.Printf("m4p: Client: recv: read udp packet failed: %v", err) + continue + } + + m, err := decode(buf[:n]) + if err != nil { + log.Printf("m4p: Client: recv: decode failed: %v", err) + continue + } + + switch m.Type { + case KeepAliveMessage: + // log.Printf("m4p: Client: recv: got %s", m.Type) + + // Trigger server keepalive, non-blocking (chan is buffered). + select { + case c.serverKeepalive <- struct{}{}: + default: + } + + goto recvLoop + + case InputMessage: + log.Printf("m4p: Client: recv: got %s: %v", m.Type, m.Input) + + case RemoteUpdateMessage: + log.Printf("m4p: Client: recv: got %s: %s", m.Type, hex.EncodeToString(m.RemoteUpdate.Payload)) + + default: + log.Printf("m4p: Client: recv: unknown message: %s", m.Type) + } + + select { + case c.recvBuf <- m: + default: + log.Printf("m4p: Client: recv: buffer full, discarding message: %s", m.Type) + } + } +} + +func (c *Client) keepalive() { + defer c.Close() + + serverDeadline := time.After(keepaliveTimeout) + clientKeepalive := time.After(clientKeepaliveInterval) + + for { + select { + case <-c.ctx.Done(): + return + + case <-c.serverKeepalive: + serverDeadline = time.After(keepaliveTimeout) + + case <-serverDeadline: + log.Printf("m4p: Client: keepalive: server keepalive deadline reached, disconnecting...") + return + + case <-clientKeepalive: + _, err := c.conn.Write([]byte("{}")) + if err != nil { + log.Printf("m4p: Client: keepalive: send client keepalive failed, disconnecting...") + return + } + clientKeepalive = time.After(clientKeepaliveInterval) + } + } +} + +// Send a message to the magic4pc server. +func (c *Client) Send(m Message) error { + b, err := json.Marshal(m) + if err != nil { + return fmt.Errorf("json encode message failed: %w", err) + } + if _, err = c.conn.Write(b); err != nil { + return fmt.Errorf("write message failed: %w", err) + } + return nil +} + +// Recv messages from the magic4pc server. Keepalives are handled +// transparently by the client and are not observable. +func (c *Client) Recv(ctx context.Context) (Message, error) { + select { + case <-ctx.Done(): + return Message{}, ctx.Err() + case <-c.ctx.Done(): + return Message{}, c.ctx.Err() + case m := <-c.recvBuf: + return m, nil + } +} + +// Close the client and connection. +func (c *Client) Close() error { + c.cancel() + return c.conn.Close() +} diff --git a/m4p/discover.go b/m4p/discover.go new file mode 100644 index 0000000..a63dde2 --- /dev/null +++ b/m4p/discover.go @@ -0,0 +1,74 @@ +package m4p + +import ( + "log" + "net" +) + +// Discoverer magic4pc servers. +type Discoverer struct { + ln *net.UDPConn + device chan DeviceInfo +} + +// NewDiscover returns a new Discoverer that listens on the broadcast port. +func NewDiscoverer(broadcastPort int) (*Discoverer, error) { + addr := net.UDPAddr{ + Port: broadcastPort, + IP: net.ParseIP("0.0.0.0"), + } + + ln, err := net.ListenUDP("udp", &addr) + if err != nil { + panic(err) + } + + d := &Discoverer{ + ln: ln, + device: make(chan DeviceInfo), // Unbuffered, discard when nobody is listening. + } + go d.discover() + + return d, nil +} + +func (d *Discoverer) discover() { + var buf [1024]byte + for { + n, addr, err := d.ln.ReadFromUDP(buf[:]) + if err != nil { + log.Printf("m4p: Discoverer: discover: read udp packet failed: %v", err) + continue + } + + m, err := decode(buf[:n]) + if err != nil { + log.Printf("m4p: Discoverer: discover: decode failed: %v", err) + } + + switch m.Type { + case Magic4PCAdMessage: + dev := m.DeviceInfo + dev.IPAddr = addr.IP.String() + log.Printf("m4p: Discoverer: discover: found device: %#v", dev) + + select { + case d.device <- *dev: + default: + } + + default: + log.Printf("m4p: Discoverer: discover: unknown message: %s", m.Type) + } + } +} + +// NextDevice returns a newly discovered magic4pc server. +func (d *Discoverer) NextDevice() <-chan DeviceInfo { + return d.device +} + +// Close the Discoverer and stop listening for broadcasts. +func (d *Discoverer) Close() error { + return d.ln.Close() +} diff --git a/m4p/m4p.go b/m4p/m4p.go new file mode 100644 index 0000000..07f78a5 --- /dev/null +++ b/m4p/m4p.go @@ -0,0 +1,46 @@ +package m4p + +import "time" + +// Protocol constants. +const ( + protocolVersion = 1 + keepaliveTimeout = 3 * time.Second + clientKeepaliveInterval = 2 * time.Second +) + +// Magic remote keycodes. +const ( + KeyWheelPressed = 13 + KeyChannelUp = 33 + KeyChannelDown = 34 + KeyLeft = 37 + KeyUp = 38 + KeyRight = 39 + KeyDown = 40 + Key0 = 48 + Key1 = 49 + Key2 = 50 + Key3 = 51 + Key4 = 52 + Key5 = 53 + Key6 = 54 + Key7 = 55 + Key8 = 56 + Key9 = 57 + KeyRed = 403 + KeyGreen = 404 + KeyYellow = 405 + KeyBlue = 406 + KeyBack = 461 +) + +// DefaultFilters used for remote updates. +var DefaultFilters = []string{ + "returnValue", + "deviceId", + "coordinate", + "gyroscope", + "acceleration", + "quaternion", +} diff --git a/m4p/message.go b/m4p/message.go new file mode 100644 index 0000000..34a9383 --- /dev/null +++ b/m4p/message.go @@ -0,0 +1,91 @@ +package m4p + +import ( + "bytes" + "encoding/json" +) + +type MessageType string + +// MessageType enums. +const ( + Magic4PCAdMessage MessageType = "magic4pc_ad" + SubSensorMessage MessageType = "sub_sensor" + RemoteUpdateMessage MessageType = "remote_update" + InputMessage MessageType = "input" + KeepAliveMessage MessageType = "keepalive" +) + +// Message format sent over the wire. +type Message struct { + Type MessageType `json:"t"` + Version int `json:"version"` + *DeviceInfo + *Register + *RemoteUpdate + *Input +} + +// NewMessage initializes a message with the type and protocol version. +func NewMessage(typ MessageType) Message { + return Message{ + Type: typ, + Version: protocolVersion, + } +} + +// DeviceInfo represents a magic4pc server. +type DeviceInfo struct { + Model string `json:"model"` + IPAddr string `json:"-"` + Port int `json:"port"` + MAC string `json:"mac"` +} + +// Register payload for registering a new client on the server. +type Register struct { + UpdateFrequency int `json:"updateFreq"` + Filter []string `json:"filter"` +} + +// Input event (key pressed). +type Input struct { + Parameters struct { + KeyCode int `json:"keyCode"` + IsDown bool `json:"isDown"` + } `json:"parameters"` +} + +// RemoteUpdate event, sensor data from the magic remote. +type RemoteUpdate struct { + // filters []string + Payload []byte `json:"payload"` +} + +// type ( +// ReturnValue uint8 +// DeviceID uint8 +// Coordinates struct{ X, Y int32 } +// Gyroscope struct{ X, Y, Z float32 } +// Acceleration struct{ X, Y, Z float32 } +// Quaternion struct{ Q0, Q1, Q2, Q3 float32 } +// ) + +// func (ru RemoteUpdate) Coordinates() Coordinates { +// return Coordinates{} +// } + +// func (ru RemoteUpdate) Acceleration() Acceleration { +// return Acceleration{} +// } + +func decode(b []byte) (Message, error) { + var m Message + dec := json.NewDecoder(bytes.NewReader(b)) + dec.DisallowUnknownFields() + if err := dec.Decode(&m); err != nil { + return Message{}, err + } + + return m, nil +} -- cgit v1.2.3