summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMathias Fredriksson <mafredri@gmail.com>2022-02-07 20:50:53 +0200
committerMathias Fredriksson <mafredri@gmail.com>2022-02-07 20:54:51 +0200
commit6436c86e0c53d9fbd83d883f76cbd8a267b6428e (patch)
tree07f90e739d93b44a7247cfb84cabba7891389e31
parentac3aa7fb134166afb5e159786db501e1e6b646fa (diff)
Implement initial PoC for Kodi
-rw-r--r--.gitignore1
-rw-r--r--LICENSE21
-rw-r--r--README.md29
-rw-r--r--cmd/magic4linux/main.go171
-rw-r--r--go.mod7
-rw-r--r--go.sum2
-rw-r--r--m4p/client.go203
-rw-r--r--m4p/discover.go74
-rw-r--r--m4p/m4p.go46
-rw-r--r--m4p/message.go91
10 files changed, 645 insertions, 0 deletions
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
+}