diff options
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | LICENSE | 21 | ||||
| -rw-r--r-- | README.md | 29 | ||||
| -rw-r--r-- | cmd/magic4linux/main.go | 171 | ||||
| -rw-r--r-- | go.mod | 7 | ||||
| -rw-r--r-- | go.sum | 2 | ||||
| -rw-r--r-- | m4p/client.go | 203 | ||||
| -rw-r--r-- | m4p/discover.go | 74 | ||||
| -rw-r--r-- | m4p/m4p.go | 46 | ||||
| -rw-r--r-- | m4p/message.go | 91 | 
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 @@ -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: +		} +	} +} @@ -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 @@ -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 +} | 
