├── go.mod ├── go.sum ├── cmd └── webostvremote │ ├── wselinfo.go │ ├── cancel.go │ ├── whelp.go │ ├── wvolume.go │ ├── store.go │ ├── slider.go │ ├── winputs.go │ ├── wapps.go │ ├── wchannels.go │ ├── wtvinfo.go │ └── main.go ├── mmediacontrols.go ├── mmediaviewer.go ├── LICENSE ├── maudio.go ├── pointersocket.go ├── README.md ├── mmisc.go ├── mtv.go ├── mapps.go └── api.go /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/snabb/webostv 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/gorilla/websocket v1.4.0 7 | github.com/mitchellh/mapstructure v1.1.2 8 | github.com/pkg/errors v0.8.1 9 | ) 10 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= 2 | github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= 3 | github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= 4 | github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= 5 | github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= 6 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 7 | -------------------------------------------------------------------------------- /cmd/webostvremote/wselinfo.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/rivo/tview" 5 | ) 6 | 7 | type selInfo struct { 8 | *tview.TextView 9 | } 10 | 11 | func newSelInfo() *selInfo { 12 | w := tview.NewTextView() 13 | w.SetBorder(true) 14 | w.SetScrollable(true) 15 | w.SetTitle("Selection Info") 16 | w.SetWrap(true) 17 | w.SetWordWrap(true) 18 | 19 | s := &selInfo{TextView: w} 20 | return s 21 | } 22 | 23 | func (s *selInfo) update(str string) { 24 | s.SetText(str) 25 | s.ScrollToBeginning() 26 | app.Draw() 27 | } 28 | -------------------------------------------------------------------------------- /cmd/webostvremote/cancel.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "sync" 5 | ) 6 | 7 | type CancelPrevious struct { 8 | ch chan struct{} 9 | sync.Mutex 10 | } 11 | 12 | func (ce *CancelPrevious) NewCancel() (ch chan struct{}) { 13 | ch = make(chan struct{}) 14 | 15 | ce.Lock() 16 | if ce.ch != nil { 17 | close(ce.ch) 18 | } 19 | ce.ch = ch 20 | ce.Unlock() 21 | 22 | return ch 23 | } 24 | 25 | func (ce *CancelPrevious) Cancel() { 26 | ce.Lock() 27 | if ce.ch != nil { 28 | close(ce.ch) 29 | } 30 | ce.ch = nil 31 | ce.Unlock() 32 | } 33 | -------------------------------------------------------------------------------- /mmediacontrols.go: -------------------------------------------------------------------------------- 1 | package webostv 2 | 3 | func (tv *Tv) MediaControlsFastForward() (err error) { 4 | _, err = tv.Request("ssap://media.controls/fastForward", nil) 5 | return err 6 | } 7 | 8 | func (tv *Tv) MediaControlsPause() (err error) { 9 | _, err = tv.Request("ssap://media.controls/pause", nil) 10 | return err 11 | } 12 | 13 | func (tv *Tv) MediaControlsPlay() (err error) { 14 | _, err = tv.Request("ssap://media.controls/play", nil) 15 | return err 16 | } 17 | 18 | func (tv *Tv) MediaControlsRewind() (err error) { 19 | _, err = tv.Request("ssap://media.controls/rewind", nil) 20 | return err 21 | } 22 | 23 | func (tv *Tv) MediaControlsStop() (err error) { 24 | _, err = tv.Request("ssap://media.controls/stop", nil) 25 | return err 26 | } 27 | -------------------------------------------------------------------------------- /cmd/webostvremote/whelp.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gdamore/tcell" 6 | "github.com/rivo/tview" 7 | ) 8 | 9 | func newHelp() tview.Primitive { 10 | w := tview.NewTextView() 11 | w.SetBorder(true) 12 | w.SetScrollable(true) 13 | w.SetTitle("Help") 14 | w.SetWrap(false) 15 | fmt.Fprintln(w, "Tab ⇥ / ⇤ next / prev") 16 | fmt.Fprintln(w, "V volume") 17 | fmt.Fprintln(w, "C channels") 18 | fmt.Fprintln(w, "I inputs") 19 | fmt.Fprintln(w, "A apps") 20 | fmt.Fprintln(w, "Enter select") 21 | fmt.Fprintln(w, "arrows move") 22 | fmt.Fprintln(w, "Q / Esc quit") 23 | fmt.Fprintln(w, "Ctrl+X turn off+quit\n") 24 | fmt.Fprintln(w, "webostvremote © J.Snabb 2018") 25 | fmt.Fprint(w, "github.com/snabb/webostv") 26 | w.SetDrawFunc(func(screen tcell.Screen, x, y, width, height int) (int, int, int, int) { 27 | w.ScrollToBeginning() 28 | return x + 1, y + 1, width - 2, height - 2 29 | }) 30 | return w 31 | } 32 | -------------------------------------------------------------------------------- /cmd/webostvremote/wvolume.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/gdamore/tcell" 5 | "github.com/rivo/tview" 6 | ) 7 | 8 | type volume struct { 9 | *Slider 10 | } 11 | 12 | func newVolume() *volume { 13 | w := NewSlider() 14 | w.SetBorder(true) 15 | w.SetTitle("Volume") 16 | return &volume{w} 17 | } 18 | 19 | func (v *volume) update(volume int) { 20 | v.SetPercent(volume) 21 | app.Draw() 22 | } 23 | 24 | func (v *volume) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { 25 | return v.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { 26 | key := event.Key() 27 | var kr rune 28 | if key == tcell.KeyRune { 29 | kr = event.Rune() 30 | } 31 | switch { 32 | case key == tcell.KeyRight || (key == tcell.KeyRune && kr == '+'): 33 | go tv.AudioVolumeUp() 34 | // XXX check err 35 | case key == tcell.KeyLeft || (key == tcell.KeyRune && kr == '-'): 36 | go tv.AudioVolumeDown() 37 | // XXX check err 38 | } 39 | }) 40 | } 41 | -------------------------------------------------------------------------------- /mmediaviewer.go: -------------------------------------------------------------------------------- 1 | package webostv 2 | 3 | func (tv *Tv) MediaViewerClose(sessionId string) (err error) { 4 | _, err = tv.Request("ssap://media.viewer/close", 5 | Payload{"sessionId": sessionId}) 6 | return err 7 | } 8 | 9 | func (tv *Tv) MediaViewerOpen(url, title, description, mimeType, iconSrc string, loop bool) (appId, sessionId string, err error) { 10 | // {"returnValue":true,"id":"com.webos.app.tvsimpleviewer","sessionId":"Y29tLndlYm9zLmFwcC50dnNpbXBsZXZpZXdlcjp1bmRlZmluZWQ="} 11 | 12 | p := make(Payload) 13 | p["target"] = url 14 | if title != "" { 15 | p["title"] = title 16 | } 17 | if description != "" { 18 | p["description"] = description 19 | } 20 | if mimeType != "" { 21 | p["mimeType"] = mimeType 22 | } 23 | if iconSrc != "" { 24 | p["iconSrc"] = iconSrc 25 | } 26 | if loop { 27 | p["loop"] = loop 28 | } 29 | var resp struct { 30 | Id string 31 | SessionId string 32 | } 33 | err = tv.RequestResponseParam("ssap://media.viewer/open", p, &resp) 34 | 35 | return resp.Id, resp.SessionId, err 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2018 Janne Snabb snabb AT epipe.com 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included 12 | in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /cmd/webostvremote/store.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "os" 7 | ) 8 | 9 | type Data map[string]string 10 | 11 | type Store struct { 12 | file *os.File 13 | data Data 14 | } 15 | 16 | func OpenStore(name string) (st *Store, err error) { 17 | f, err := os.OpenFile(name, os.O_RDWR|os.O_CREATE, 0666) 18 | if err != nil { 19 | return nil, err 20 | } 21 | st = &Store{ 22 | file: f, 23 | } 24 | err = st.readAll() 25 | if err != nil { 26 | st.file.Close() 27 | return nil, err 28 | } 29 | if st.data == nil { 30 | st.data = make(Data) 31 | } 32 | return st, nil 33 | } 34 | 35 | func (st *Store) readAll() (err error) { 36 | _, err = st.file.Seek(0, io.SeekStart) 37 | if err != nil { 38 | return err 39 | } 40 | err = json.NewDecoder(st.file).Decode(&st.data) 41 | if err != nil && err != io.EOF { 42 | return err 43 | } 44 | return nil 45 | } 46 | 47 | func (st *Store) writeAll() (err error) { 48 | _, err = st.file.Seek(0, io.SeekStart) 49 | if err != nil { 50 | return err 51 | } 52 | err = json.NewEncoder(st.file).Encode(st.data) 53 | if err != nil { 54 | return err 55 | } 56 | pos, err := st.file.Seek(0, io.SeekCurrent) 57 | if err != nil { 58 | return err 59 | } 60 | err = st.file.Truncate(pos) 61 | if err != nil { 62 | return err 63 | } 64 | return nil 65 | } 66 | 67 | func (st *Store) Get(key string) (value string) { 68 | return st.data[key] 69 | } 70 | 71 | func (st *Store) Set(key, value string) (err error) { 72 | if st.data[key] == value { 73 | return nil 74 | } 75 | st.data[key] = value 76 | return st.writeAll() 77 | } 78 | 79 | func (st *Store) Close() (err error) { 80 | err = st.file.Close() 81 | st.data = nil 82 | return err 83 | } 84 | -------------------------------------------------------------------------------- /maudio.go: -------------------------------------------------------------------------------- 1 | package webostv 2 | 3 | import ( 4 | "github.com/mitchellh/mapstructure" 5 | ) 6 | 7 | func (tv *Tv) AudioGetMute() (mute bool, err error) { 8 | // "payload":{"mute":false,"returnValue":true} 9 | var resp struct { 10 | Mute bool 11 | } 12 | err = tv.RequestResponseParam("ssap://audio/getMute", nil, &resp) 13 | return resp.Mute, err 14 | } 15 | 16 | type AudioStatus struct { 17 | Scenario string 18 | Volume int 19 | Mute bool 20 | } 21 | 22 | func (tv *Tv) AudioGetStatus() (as AudioStatus, err error) { 23 | // "payload":{"returnValue":true,"scenario":"mastervolume_tv_speaker","volume":9,"mute":false} 24 | err = tv.RequestResponseParam("ssap://audio/getStatus", nil, &as) 25 | return as, err 26 | } 27 | 28 | func (tv *Tv) AudioMonitorStatus(process func(as AudioStatus) error, quit <-chan struct{}) error { 29 | return tv.MonitorStatus("ssap://audio/getStatus", nil, func(payload Payload) (err error) { 30 | var as AudioStatus 31 | err = mapstructure.Decode(payload, &as) 32 | if err == nil { 33 | err = process(as) 34 | } 35 | return err 36 | }, quit) 37 | } 38 | 39 | func (tv *Tv) AudioGetVolume() (scenario string, volume int, muted bool, err error) { 40 | // "payload":{"returnValue":true,"scenario":"mastervolume_tv_speaker","volume":9,"muted":false} 41 | var resp struct { 42 | Scenario string 43 | Volume int 44 | Muted bool 45 | } 46 | err = tv.RequestResponseParam("ssap://audio/getVolume", nil, &resp) 47 | return resp.Scenario, resp.Volume, resp.Muted, err 48 | } 49 | 50 | func (tv *Tv) AudioSetMute(mute bool) (err error) { 51 | _, err = tv.Request("ssap://audio/setMute", 52 | Payload{"mute": mute}) 53 | return err 54 | } 55 | 56 | func (tv *Tv) AudioSetVolume(volume int) (err error) { 57 | _, err = tv.Request("ssap://audio/setVolume", 58 | Payload{"volume": volume}) 59 | return err 60 | } 61 | 62 | func (tv *Tv) AudioVolumeDown() (err error) { 63 | _, err = tv.Request("ssap://audio/volumeDown", nil) 64 | return err 65 | } 66 | 67 | func (tv *Tv) AudioVolumeUp() (err error) { 68 | _, err = tv.Request("ssap://audio/volumeUp", nil) 69 | return err 70 | } 71 | -------------------------------------------------------------------------------- /cmd/webostvremote/slider.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gdamore/tcell" 6 | "github.com/rivo/tview" 7 | "sync" 8 | ) 9 | 10 | type Slider struct { 11 | *tview.Box 12 | percent int 13 | percentMutex sync.Mutex 14 | BarColor tcell.Color 15 | NoBarColor tcell.Color 16 | IndicatorOnBarColor tcell.Color 17 | IndicatorNoBarColor tcell.Color 18 | IndicatorFormat string 19 | IndicatorAlign int 20 | } 21 | 22 | func NewSlider() *Slider { 23 | return &Slider{ 24 | Box: tview.NewBox(), 25 | BarColor: tview.Styles.GraphicsColor, 26 | NoBarColor: tview.Styles.ContrastBackgroundColor, 27 | IndicatorOnBarColor: tview.Styles.ContrastSecondaryTextColor, 28 | IndicatorNoBarColor: tview.Styles.SecondaryTextColor, 29 | IndicatorFormat: "%d%%", 30 | IndicatorAlign: tview.AlignCenter, 31 | } 32 | } 33 | 34 | func (s *Slider) SetPercent(v int) { 35 | s.percentMutex.Lock() 36 | s.percent = v 37 | s.percentMutex.Unlock() 38 | } 39 | 40 | func (s *Slider) GetPercent() (v int) { 41 | s.percentMutex.Lock() 42 | v = s.percent 43 | s.percentMutex.Unlock() 44 | return v 45 | } 46 | 47 | func (s *Slider) Draw(screen tcell.Screen) { 48 | s.Box.Draw(screen) 49 | x, y, width, height := s.GetInnerRect() 50 | 51 | if height < 1 { 52 | return 53 | } 54 | percent := s.GetPercent() 55 | 56 | var indicator []rune 57 | if s.IndicatorFormat != "" { 58 | indicator = []rune(fmt.Sprintf(s.IndicatorFormat, percent)) 59 | } 60 | var ipos int 61 | if indicator != nil { 62 | switch s.IndicatorAlign { 63 | case tview.AlignLeft: 64 | ipos = 0 65 | case tview.AlignCenter: 66 | ipos = (width - len(indicator)) / 2 67 | case tview.AlignRight: 68 | ipos = width - len(indicator) - 1 69 | } 70 | } 71 | 72 | for i := 0; i < width; i++ { 73 | c := ' ' 74 | var style tcell.Style 75 | if i*100/width >= percent { 76 | style = tcell.StyleDefault. 77 | Foreground(s.IndicatorNoBarColor). 78 | Background(s.NoBarColor) 79 | } else { 80 | style = tcell.StyleDefault. 81 | Foreground(s.IndicatorOnBarColor). 82 | Background(s.BarColor) 83 | } 84 | if indicator != nil && i >= ipos && i-ipos < len(indicator) { 85 | c = indicator[i-ipos] 86 | } 87 | screen.SetContent(x+i, y, c, nil, style) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /pointersocket.go: -------------------------------------------------------------------------------- 1 | package webostv 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gorilla/websocket" 6 | "sync" 7 | ) 8 | 9 | type PointerSocket struct { 10 | Address string 11 | ws *websocket.Conn 12 | sync.Mutex 13 | } 14 | 15 | func (dialer *Dialer) DialPointerSocket(address string) (ps *PointerSocket, err error) { 16 | wsDialer := dialer.WebsocketDialer 17 | if wsDialer == nil { 18 | wsDialer = websocket.DefaultDialer 19 | } 20 | ws, resp, err := wsDialer.Dial(address, nil) 21 | if err != nil { 22 | return nil, err 23 | } 24 | err = resp.Body.Close() 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | return &PointerSocket{ 30 | Address: address, 31 | ws: ws, 32 | }, nil 33 | } 34 | 35 | func (tv *Tv) NewPointerSocket() (ps *PointerSocket, err error) { 36 | socketPath, err := tv.GetPointerInputSocket() 37 | if err != nil { 38 | return nil, err 39 | } 40 | return DefaultDialer.DialPointerSocket(socketPath) 41 | } 42 | 43 | func (ps *PointerSocket) MessageHandler() (err error) { 44 | for { 45 | _, _, err = ps.ws.ReadMessage() 46 | if err != nil { 47 | return err 48 | } 49 | } 50 | // not reached 51 | } 52 | 53 | func (ps *PointerSocket) Close() (err error) { 54 | ps.Lock() 55 | defer ps.Unlock() 56 | if ps.ws != nil { 57 | err = ps.ws.Close() 58 | ps.ws = nil 59 | } 60 | return err 61 | } 62 | 63 | func (ps *PointerSocket) writeMessage(messageType int, data []byte) error { 64 | ps.Lock() 65 | defer ps.Unlock() 66 | return ps.ws.WriteMessage(messageType, data) 67 | } 68 | 69 | func (ps *PointerSocket) Input(btype, bname string) (err error) { 70 | msg := "type:" + btype + "\n" + "name:" + bname + "\n\n" 71 | return ps.writeMessage(websocket.TextMessage, []byte(msg)) 72 | } 73 | 74 | // UP DOWN LEFT RIGHT HOME BACK DASH and numbers 75 | 76 | func (ps *PointerSocket) Move(dx, dy int) (err error) { 77 | msg := fmt.Sprintf("type:move\ndx:%d\ndy:%d\ndown:0\n\n", dx, dy) 78 | return ps.writeMessage(websocket.TextMessage, []byte(msg)) 79 | } 80 | 81 | func (ps *PointerSocket) Scroll(dx, dy int) (err error) { 82 | msg := fmt.Sprintf("type:scroll\ndx:%d\ndy:%d\ndown:0\n\n", dx, dy) 83 | return ps.writeMessage(websocket.TextMessage, []byte(msg)) 84 | } 85 | 86 | func (ps *PointerSocket) Click() (err error) { 87 | msg := "type: click\n\n" 88 | return ps.writeMessage(websocket.TextMessage, []byte(msg)) 89 | } 90 | -------------------------------------------------------------------------------- /cmd/webostvremote/winputs.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/pkg/errors" 6 | "github.com/rivo/tview" 7 | "github.com/snabb/webostv" 8 | "sync" 9 | ) 10 | 11 | type inputs struct { 12 | *tview.Table 13 | inputs []webostv.TvExternalInput 14 | inputsMutex sync.Mutex 15 | updateInfo func(str string) 16 | } 17 | 18 | func newInputs() *inputs { 19 | w := tview.NewTable() 20 | w.SetBorder(true) 21 | w.SetTitle("Inputs") 22 | w.SetSelectable(false, false) 23 | 24 | i := &inputs{Table: w} 25 | w.SetSelectedFunc(i.selected) 26 | w.SetSelectionChangedFunc(i.selectionChanged) 27 | 28 | return i 29 | } 30 | 31 | func (i *inputs) selected(row, column int) { 32 | var sel webostv.TvExternalInput 33 | set := false 34 | i.inputsMutex.Lock() 35 | if i.inputs != nil && row < len(i.inputs) { 36 | sel = i.inputs[row] 37 | set = true 38 | } 39 | i.inputsMutex.Unlock() 40 | if set { 41 | go tv.TvSwitchInput(sel.Id) 42 | // XXX check err 43 | } 44 | } 45 | 46 | func (i *inputs) selectionChanged(row, column int) { 47 | if i.updateInfo == nil { 48 | return 49 | } 50 | var sel webostv.TvExternalInput 51 | set := false 52 | i.inputsMutex.Lock() 53 | if i.inputs != nil && row < len(i.inputs) { 54 | sel = i.inputs[row] 55 | set = true 56 | } 57 | i.inputsMutex.Unlock() 58 | if !set { 59 | i.updateInfo("") 60 | return 61 | } 62 | i.updateInfo(fmt.Sprintf("Input label: %s\nconnected: %v, favorite: %v, autoav: %v\nid: %s, appId: %s", sel.Label, sel.Connected, sel.Favorite, sel.Autoav, sel.Id, sel.AppId)) 63 | } 64 | 65 | func (i *inputs) updateFromTv() (err error) { 66 | tvInputs, err := tv.TvGetExternalInputList() 67 | if err != nil { 68 | return errors.Wrap(err, "error updating inputs from TV") 69 | } 70 | 71 | /* 72 | sort.Slice(tvChannels, func(i, j int) bool { 73 | li := len(tvChannels[i].ChannelNumber) 74 | lj := len(tvChannels[j].ChannelNumber) 75 | 76 | if li < lj { 77 | return true 78 | } 79 | if li > lj { 80 | return false 81 | } 82 | return tvChannels[i].ChannelNumber < tvChannels[j].ChannelNumber 83 | }) 84 | */ 85 | i.update(tvInputs) 86 | return nil 87 | } 88 | 89 | func (i *inputs) update(tvInputs []webostv.TvExternalInput) { 90 | i.inputsMutex.Lock() 91 | i.inputs = tvInputs 92 | i.inputsMutex.Unlock() 93 | 94 | i.Clear() 95 | for row, input := range tvInputs { 96 | i.SetCell(row, 0, tview.NewTableCell(input.Label)) 97 | i.SetCell(row, 1, tview.NewTableCell(fmt.Sprintf("%v", input.Connected))) 98 | } 99 | i.ScrollToBeginning() 100 | } 101 | -------------------------------------------------------------------------------- /cmd/webostvremote/wapps.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/pkg/errors" 5 | "github.com/rivo/tview" 6 | "github.com/snabb/webostv" 7 | "sync" 8 | ) 9 | 10 | type apps struct { 11 | *tview.Table 12 | apps []webostv.LaunchPoint 13 | appsMutex sync.Mutex 14 | updateInfo func(str string) 15 | } 16 | 17 | func newApps() *apps { 18 | w := tview.NewTable() 19 | w.SetBorder(true) 20 | w.SetTitle("Apps") 21 | w.SetSelectable(false, false) 22 | 23 | a := &apps{Table: w} 24 | w.SetSelectedFunc(a.selected) 25 | w.SetSelectionChangedFunc(a.selectionChanged) 26 | 27 | return a 28 | } 29 | 30 | func (a *apps) appNames() (appNames map[string]string) { 31 | appNames = make(map[string]string) 32 | 33 | a.appsMutex.Lock() 34 | for _, app := range a.apps { 35 | appNames[app.Id] = app.Title 36 | } 37 | a.appsMutex.Unlock() 38 | return appNames 39 | } 40 | 41 | func (a *apps) selected(row, column int) { 42 | var sel webostv.LaunchPoint 43 | set := false 44 | a.appsMutex.Lock() 45 | if a.apps != nil && row < len(a.apps) { 46 | sel = a.apps[row] 47 | set = true 48 | } 49 | a.appsMutex.Unlock() 50 | if !set { 51 | return 52 | } 53 | payload := make(webostv.Payload) 54 | for k, v := range sel.Params { 55 | payload[k] = v 56 | } 57 | 58 | go tv.ApplicationManagerLaunch(sel.Id, payload) 59 | // XXX check err 60 | } 61 | 62 | func (a *apps) selectionChanged(row, column int) { 63 | if a.updateInfo == nil { 64 | return 65 | } 66 | var sel webostv.LaunchPoint 67 | set := false 68 | a.appsMutex.Lock() 69 | if a.apps != nil && row < len(a.apps) { 70 | sel = a.apps[row] 71 | set = true 72 | } 73 | a.appsMutex.Unlock() 74 | if !set { 75 | a.updateInfo("") 76 | return 77 | } 78 | a.updateInfo("App title: " + sel.Title + "\n" + 79 | "vendor: " + sel.Vendor + "\n" + 80 | "version: " + sel.Version + "\n" + 81 | "id: " + sel.Id) 82 | } 83 | 84 | func (a *apps) updateFromTv() (err error) { 85 | tvApps, _, err := tv.ApplicationManagerListLaunchPoints() 86 | if err != nil { 87 | return errors.Wrap(err, "error updating apps from TV") 88 | } 89 | 90 | /* 91 | sort.Slice(tvChannels, func(i, j int) bool { 92 | li := len(tvChannels[i].ChannelNumber) 93 | lj := len(tvChannels[j].ChannelNumber) 94 | 95 | if li < lj { 96 | return true 97 | } 98 | if li > lj { 99 | return false 100 | } 101 | return tvChannels[i].ChannelNumber < tvChannels[j].ChannelNumber 102 | }) 103 | */ 104 | a.update(tvApps) 105 | return nil 106 | } 107 | 108 | func (a *apps) update(tvApps []webostv.LaunchPoint) { 109 | a.appsMutex.Lock() 110 | a.apps = tvApps 111 | a.appsMutex.Unlock() 112 | 113 | a.Clear() 114 | for row, app := range tvApps { 115 | a.SetCell(row, 0, tview.NewTableCell(app.Title)) 116 | a.SetCell(row, 1, tview.NewTableCell(app.Vendor)) 117 | } 118 | a.ScrollToBeginning() 119 | } 120 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | webostv - Go package for controlling LG WebOS TV 2 | ================================================ 3 | 4 | [![GoDoc](https://godoc.org/github.com/snabb/webostv?status.svg)](https://godoc.org/github.com/snabb/webostv) 5 | 6 | This is Go library and a terminal application for remote control of 7 | LG WebOS smart televisions. Works on Linux and Windows and probably 8 | on OS X as well. It has been tested with LG 42LB650V-ZN television. 9 | 10 | 11 | Installing and using the remote control application 12 | --------------------------------------------------- 13 | 14 | Download pre-built executable binary for your OS/architecture from: 15 | https://github.com/snabb/webostv/releases 16 | 17 | Run the downloaded application in a terminal window. On Linux, add execute 18 | permissions after downloading (`chmod 755 webostvremote-linux-amd64`). The 19 | IP address or name of the TV can be given as a command line argument: 20 | ``` 21 | ./webostvremote 192.0.2.123 22 | ``` 23 | If the address is not supplied, it will try to connect to the default 24 | address `LGsmartTV.lan`. 25 | 26 | 27 | Building the remote control application from source 28 | --------------------------------------------------- 29 | 30 | Install Go compiler if you do not have it: 31 | ``` 32 | curl https://dl.google.com/go/go1.12.linux-amd64.tar.gz | sudo tar xzC /usr/local 33 | PATH=$PATH:/usr/local/go/bin 34 | ``` 35 | (See https://golang.org/dl/ for newer version and more detailed 36 | instructions.) 37 | 38 | Compile: 39 | ``` 40 | git clone https://github.com/snabb/webostv.git 41 | cd webostv 42 | go build ./cmd/webostvremote 43 | ``` 44 | The compiled binary `webostvremote` is produced in the current working 45 | directory. If there are errors, try again with up-to-date Go compiler 46 | version. 47 | 48 | 49 | Simple example of using the library to turn off the TV 50 | ------------------------------------------------------ 51 | 52 | ```Go 53 | package main 54 | 55 | import "github.com/snabb/webostv" 56 | 57 | func main() { 58 | tv, err := webostv.DefaultDialer.Dial("LGsmartTV.lan") 59 | if err != nil { 60 | panic(err) 61 | } 62 | defer tv.Close() 63 | go tv.MessageHandler() 64 | 65 | _, err = tv.Register("") 66 | if err != nil { 67 | panic(err) 68 | } 69 | 70 | err = tv.SystemTurnOff() 71 | if err != nil { 72 | panic(err) 73 | } 74 | } 75 | ``` 76 | 77 | Unimplemented / TODO 78 | -------------------- 79 | 80 | ### webostv library 81 | 82 | - Documentation. 83 | - Consider the method names, some could be shortened. 84 | - PIN based pairing. 85 | - UPnP discovery? 86 | - Add missing subscriptions? 87 | - Play media? 88 | 89 | ### webostvremote application 90 | 91 | - Documentation. 92 | - Volume mute. 93 | - Make "store" a separate generic package (or find pre-existing one). 94 | - Make it look better. 95 | * Colors. 96 | * Clarify channel + program info etc. 97 | - Error popup? 98 | - Program guide. 99 | - TV mouse control. 100 | - TV keyboard input. 101 | - Play media? 102 | 103 | 104 | License 105 | ------- 106 | 107 | MIT 108 | -------------------------------------------------------------------------------- /cmd/webostvremote/wchannels.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/pkg/errors" 6 | "github.com/rivo/tview" 7 | "github.com/snabb/webostv" 8 | "sort" 9 | "sync" 10 | "time" 11 | ) 12 | 13 | type channels struct { 14 | *tview.Table 15 | channels []webostv.TvChannel 16 | channelsMutex sync.Mutex 17 | updateInfo func(str string) 18 | cancelPreviousGetChannelCurrentProgramInfo CancelPrevious 19 | } 20 | 21 | func newChannels() *channels { 22 | w := tview.NewTable() 23 | w.SetBorder(true) 24 | w.SetTitle("Channels") 25 | w.SetSelectable(false, false) 26 | 27 | c := &channels{Table: w} 28 | w.SetSelectedFunc(c.selected) 29 | w.SetSelectionChangedFunc(c.selectionChanged) 30 | 31 | return c 32 | } 33 | 34 | func (c *channels) cancelTasks() { 35 | c.cancelPreviousGetChannelCurrentProgramInfo.Cancel() 36 | } 37 | 38 | func (c *channels) selected(row, column int) { 39 | var sel webostv.TvChannel 40 | set := false 41 | c.channelsMutex.Lock() 42 | if c.channels != nil && row < len(c.channels) { 43 | sel = c.channels[row] 44 | set = true 45 | } 46 | c.channelsMutex.Unlock() 47 | if set { 48 | go tv.TvOpenChannelId(sel.ChannelId) 49 | // XXX check err 50 | } 51 | } 52 | 53 | func (c *channels) selectionChanged(row, column int) { 54 | if c.updateInfo == nil { 55 | return 56 | } 57 | var sel webostv.TvChannel 58 | set := false 59 | c.channelsMutex.Lock() 60 | if c.channels != nil && row < len(c.channels) { 61 | sel = c.channels[row] 62 | set = true 63 | } 64 | c.channelsMutex.Unlock() 65 | if !set { 66 | c.updateInfo("") 67 | return 68 | } 69 | updBasic := fmt.Sprintf("Channel number: %s, name: %s\ntype: %s, hdtv: %v\nsignal id: %s, frequency: %d", sel.ChannelNumber, sel.ChannelName, sel.ChannelType, sel.HDTV, sel.SignalChannelId, sel.Frequency) 70 | 71 | c.updateInfo(updBasic) 72 | 73 | go func() { 74 | cancelCh := c.cancelPreviousGetChannelCurrentProgramInfo.NewCancel() 75 | // throttle 76 | select { 77 | case <-cancelCh: 78 | app.logger.Debug("canceled") 79 | return 80 | case <-time.After(time.Millisecond * 500): 81 | } 82 | 83 | info, err := tv.TvGetChannelCurrentProgramInfo(sel.ChannelId) 84 | if err != nil { 85 | app.logger.Error("TvGetChannelCurrentProgramInfo error", "channelId", sel.ChannelId, "err", err) 86 | return 87 | } 88 | c.cancelPreviousGetChannelCurrentProgramInfo.Lock() 89 | defer c.cancelPreviousGetChannelCurrentProgramInfo.Unlock() 90 | select { 91 | case <-cancelCh: 92 | app.logger.Debug("canceled") 93 | return 94 | default: 95 | } 96 | c.updateInfo(updBasic + fmt.Sprintf("\ncurrent program: %s\nstart: %s, end: %s, duration %d\ndescription: %s", info.ProgramName, info.LocalStartTime, info.LocalEndTime, info.Duration, info.Description)) 97 | }() 98 | } 99 | 100 | func (c *channels) updateFromTv() (err error) { 101 | tvChannels, err := tv.TvGetChannelList() 102 | if err != nil { 103 | return errors.Wrap(err, "error updating channels from TV") 104 | } 105 | 106 | sort.Slice(tvChannels, func(i, j int) bool { 107 | li := len(tvChannels[i].ChannelNumber) 108 | lj := len(tvChannels[j].ChannelNumber) 109 | 110 | if li < lj { 111 | return true 112 | } 113 | if li > lj { 114 | return false 115 | } 116 | return tvChannels[i].ChannelNumber < tvChannels[j].ChannelNumber 117 | }) 118 | c.update(tvChannels) 119 | return nil 120 | } 121 | 122 | func (c *channels) update(tvChannels []webostv.TvChannel) { 123 | c.channelsMutex.Lock() 124 | c.channels = tvChannels 125 | c.channelsMutex.Unlock() 126 | 127 | c.Clear() 128 | for row, ch := range tvChannels { 129 | var info string 130 | if ch.HDTV { 131 | info = "HDTV" 132 | } else if ch.TV { 133 | info = "TV" 134 | } else if ch.Radio { 135 | info = "Radio" 136 | } 137 | 138 | c.SetCell(row, 0, tview.NewTableCell(ch.ChannelNumber).SetAlign(tview.AlignRight)) 139 | c.SetCell(row, 1, tview.NewTableCell(ch.ChannelName)) 140 | c.SetCell(row, 2, tview.NewTableCell(info)) 141 | } 142 | c.ScrollToBeginning() 143 | } 144 | -------------------------------------------------------------------------------- /cmd/webostvremote/wtvinfo.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gdamore/tcell" 6 | "github.com/pkg/errors" 7 | "github.com/rivo/tview" 8 | "github.com/snabb/webostv" 9 | "sync" 10 | "time" 11 | ) 12 | 13 | type tvInfo struct { 14 | *tview.TextView 15 | sync.Mutex 16 | systemInfo webostv.SystemInfo 17 | foregroundAppInfo webostv.ForegroundAppInfo 18 | appNames map[string]string 19 | tvCurrentChannel webostv.TvCurrentChannel 20 | } 21 | 22 | func newTvInfo() *tvInfo { 23 | w := tview.NewTextView() 24 | w.SetBorder(true) 25 | w.SetScrollable(true) 26 | w.SetTitle("TV Information") 27 | w.SetWrap(false) 28 | w.SetDrawFunc(func(screen tcell.Screen, x, y, width, height int) (int, int, int, int) { 29 | w.ScrollToBeginning() 30 | return x + 1, y + 1, width - 2, height - 2 31 | }) 32 | 33 | i := &tvInfo{TextView: w} 34 | 35 | return i 36 | } 37 | 38 | func (i *tvInfo) updateFromTv() (err error) { 39 | systemInfo, err := tv.SystemGetSystemInfo() 40 | if err != nil { 41 | return errors.Wrap(err, "error getting system info from TV") 42 | } 43 | 44 | appNames := make(map[string]string) 45 | 46 | apps, err := tv.ApplicationManagerListApps() 47 | if err != nil { 48 | return errors.Wrap(err, "error getting app list from TV") 49 | } 50 | for _, app := range apps { 51 | appNames[app.Id] = app.Title 52 | } 53 | 54 | i.Lock() 55 | i.systemInfo = systemInfo 56 | i.appNames = appNames 57 | i.update() 58 | i.Unlock() 59 | return nil 60 | } 61 | 62 | func (i *tvInfo) update() { 63 | systemInfo := i.systemInfo 64 | foregroundAppInfo := i.foregroundAppInfo 65 | tvCurrentChannel := i.tvCurrentChannel 66 | 67 | i.Clear() 68 | 69 | fmt.Fprintf(i, "Address %s", tv.Address) 70 | fmt.Fprintf(i, "\nModel %s", systemInfo.ModelName) 71 | fmt.Fprintf(i, "\nReceiver %s", systemInfo.ReceiverType) 72 | 73 | var features string 74 | for k, v := range systemInfo.Features { 75 | if !v { 76 | continue 77 | } 78 | if features == "" { 79 | features += k 80 | } else { 81 | features += ", " + k 82 | } 83 | } 84 | fmt.Fprintf(i, "\nFeatures %s", features) 85 | 86 | appName := foregroundAppInfo.AppId 87 | if i.appNames != nil { 88 | str := i.appNames[appName] 89 | if str != "" { 90 | appName = str 91 | } 92 | } 93 | fmt.Fprintf(i, "\nApp %s", appName) 94 | 95 | if tvCurrentChannel.ChannelNumber != "" { 96 | fmt.Fprintf(i, "\nChannel %s • %s", tvCurrentChannel.ChannelNumber, tvCurrentChannel.ChannelName) 97 | fmt.Fprintf(i, "\n %s", tvCurrentChannel.ChannelTypeName) 98 | } else { 99 | fmt.Fprint(i, "\n\n") 100 | } 101 | 102 | i.ScrollToBeginning() 103 | } 104 | 105 | func (i *tvInfo) monitorTvCurrentInfo(quit chan struct{}) (err error) { 106 | var channelQuitCh chan struct{} 107 | errorCh := make(chan error, 1) 108 | 109 | err = tv.ApplicationManagerMonitorForegroundAppInfo(func(info webostv.ForegroundAppInfo) error { 110 | var startChannelMonitor, stopChannelMonitor bool 111 | i.Lock() 112 | if info.IsLiveTv() && !i.foregroundAppInfo.IsLiveTv() { 113 | startChannelMonitor = true 114 | } else if !info.IsLiveTv() && i.foregroundAppInfo.IsLiveTv() { 115 | stopChannelMonitor = true 116 | } 117 | i.foregroundAppInfo = info 118 | i.update() 119 | i.Unlock() 120 | app.Draw() 121 | 122 | if startChannelMonitor { 123 | app.logger.Debug("starting channel monitor") 124 | channelQuitCh = make(chan struct{}) 125 | go func() { 126 | err := i.monitorTvCurrentChannel(channelQuitCh) 127 | errorCh <- err 128 | if err != nil { 129 | close(quit) 130 | } 131 | }() 132 | } else if stopChannelMonitor { 133 | app.logger.Debug("stopping channel monitor") 134 | close(channelQuitCh) 135 | err := <-errorCh 136 | channelQuitCh = nil 137 | i.Lock() 138 | i.tvCurrentChannel = webostv.TvCurrentChannel{} 139 | i.update() 140 | i.Unlock() 141 | app.Draw() 142 | if err != nil { 143 | return err 144 | } 145 | } 146 | 147 | return nil 148 | }, quit) 149 | 150 | if channelQuitCh != nil { 151 | close(channelQuitCh) 152 | } 153 | if err != nil { 154 | return err 155 | } 156 | select { 157 | case err = <-errorCh: 158 | default: 159 | } 160 | return err 161 | } 162 | 163 | var errRetry = errors.New("retry") 164 | 165 | func (i *tvInfo) monitorTvCurrentChannel(quit chan struct{}) (err error) { 166 | for { 167 | err = tv.TvMonitorCurrentChannel(func(cur webostv.TvCurrentChannel) error { 168 | app.logger.Debug("got current channel message", "cur", cur) 169 | if cur.ChannelNumber == "0" && cur.IsSkipped { 170 | // this happens if we have sent the subscription message too quickly 171 | return errRetry 172 | } 173 | i.Lock() 174 | i.tvCurrentChannel = cur 175 | i.update() 176 | i.Unlock() 177 | app.Draw() 178 | return nil 179 | }, quit) 180 | 181 | if err == errRetry { 182 | select { 183 | case <-time.After(time.Second): 184 | app.logger.Debug("retrying current channel subscription") 185 | continue 186 | case <-quit: 187 | return nil 188 | } 189 | } 190 | return err 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /mmisc.go: -------------------------------------------------------------------------------- 1 | package webostv 2 | 3 | type ServiceListEntry struct { 4 | Name string 5 | Version int 6 | } 7 | 8 | func (tv *Tv) ApiGetServiceList() (list []ServiceListEntry, err error) { 9 | // "payload":{"services":[{"name":"api","version":1},{"name":"audio","version":1},{"name":"media.controls","version":1},{"name":"media.viewer","version":1},{"name":"pairing","version":1},{"name":"system","version":1},{"name":"system.launcher","version":1},{"name":"system.notifications","version":1},{"name":"tv","version":1},{"name":"webapp","version":2}],"returnValue":true} 10 | var resp struct { 11 | Services []ServiceListEntry 12 | } 13 | err = tv.RequestResponseParam("ssap://api/getServiceList", nil, &resp) 14 | return resp.Services, err 15 | } 16 | 17 | // TODO ssap://com.webos.service.apiadapter/audio/changeSoundOutput 18 | // TODO ssap://com.webos.service.apiadapter/audio/getSoundOutput // 404 no such service or method 19 | // TODO ssap://com.webos.service.appstatus/getAppStatus // 404 no such service or method 20 | // TODO ssap://com.webos.service.bluetooth/gap/findDevices 21 | // TODO ssap://com.webos.service.bluetooth/gap/getTrustedDevices 22 | // TODO ssap://com.webos.service.bluetooth/gap/isWiFiOnly 23 | // TODO ssap://com.webos.service.bluetooth/gap/removeTrustedDevice 24 | // TODO ssap://com.webos.service.bluetooth/service/connect 25 | // TODO ssap://com.webos.service.bluetooth/service/disconnect 26 | // TODO ssap://com.webos.service.bluetooth/service/getStates 27 | // TODO ssap://com.webos.service.bluetooth/service/subscribeNotifications 28 | // TODO ssap://com.webos.service.connectionmanager/getinfo // 404 no such service or method 29 | 30 | func (tv *Tv) ImeDeleteCharacters(count int) (err error) { 31 | _, err = tv.Request("ssap://com.webos.service.ime/deleteCharacters", 32 | Payload{"count": count}) 33 | return err 34 | } 35 | 36 | func (tv *Tv) ImeInsertText(text string, replace bool) (err error) { 37 | _, err = tv.Request("ssap://com.webos.service.ime/insertText", 38 | Payload{ 39 | "text": text, 40 | "replace": replace, 41 | }) 42 | return err 43 | } 44 | 45 | // TODO ssap://com.webos.service.ime/registerRemoteKeyboard 46 | 47 | // {"type":"response","id":"nlHxhqwT","payload":{"currentWidget":{"autoCapitalizationEnabled":true,"contentType":"text","correctionEnabled":false,"cursorPosition":13,"focus":true,"hasSurroundingText":true,"hiddenText":false,"predictionEnabled":true,"surroundingTextLength":13},"focusChanged":true}} 48 | 49 | func (tv *Tv) ImeSendEnterKey() (err error) { 50 | _, err = tv.Request("ssap://com.webos.service.ime/sendEnterKey", nil) 51 | return err 52 | } 53 | 54 | // TODO ssap://com.webos.service.miracast/close 55 | // TODO ssap://com.webos.service.miracast/getConnectionStatus 56 | // TODO ssap://com.webos.service.miracast/getP2pState 57 | // TODO ssap://com.webos.service.miracast/setUACSettings 58 | // TODO ssap://com.webos.service.miracast/uibc/getUibcKeyEvent 59 | 60 | func (tv *Tv) GetPointerInputSocket() (socketPath string, err error) { 61 | // "payload":{"returnValue":true,"scenario":"mastervolume_tv_speaker","volume":9,"muted":false} 62 | var resp struct { 63 | SocketPath string 64 | } 65 | err = tv.RequestResponseParam("ssap://com.webos.service.networkinput/getPointerInputSocket", nil, &resp) 66 | return resp.SocketPath, err 67 | } 68 | 69 | func (tv *Tv) SdxGetHttpHeaderForServiceRequest() (resp map[string]string, err error) { 70 | // {"clearedForDuty":true,"returnValue":true} 71 | tmpresp, err := tv.Request("ssap://com.webos.service.sdx/getHttpHeaderForServiceRequest", nil) 72 | if tmpresp != nil { 73 | resp = make(map[string]string) 74 | } 75 | 76 | for k, v := range tmpresp { 77 | if v, ok := v.(string); ok { 78 | resp[k] = v 79 | } 80 | } 81 | return resp, err 82 | } 83 | 84 | func (tv *Tv) SecondscreenGatewayTestSecure() (clearedForDuty bool, err error) { 85 | // {"clearedForDuty":true,"returnValue":true} 86 | var resp struct { 87 | ClearedForDuty bool 88 | } 89 | err = tv.RequestResponseParam("ssap://com.webos.service.secondscreen.gateway/test/secure", nil, &resp) 90 | return resp.ClearedForDuty, err 91 | } 92 | 93 | func (tv *Tv) Get3DStatus() (status bool, pattern string, err error) { 94 | // {"returnValue":true,"status3D":{"status":true,"pattern":"2dto3d"} 95 | var resp struct { 96 | Status3D struct { 97 | Status bool 98 | Pattern string 99 | } 100 | } 101 | err = tv.RequestResponseParam("ssap://com.webos.service.tv.display/get3DStatus", nil, &resp) 102 | return resp.Status3D.Status, resp.Status3D.Pattern, err 103 | } 104 | 105 | func (tv *Tv) Set3DOff() (err error) { 106 | _, err = tv.Request("ssap://com.webos.service.tv.display/set3DOff", nil) 107 | return err 108 | } 109 | 110 | func (tv *Tv) Set3DOn() (err error) { 111 | _, err = tv.Request("ssap://com.webos.service.tv.display/set3DOn", nil) 112 | return err 113 | } 114 | 115 | // TODO ssap://com.webos.service.tv.keymanager/listInterestingEvents // error KEYMANAGER_ERROR_0001: Required parameter does not exist - subscribe 116 | // TODO ssap://com.webos.service.tvpower/power/getPowerState // 404 no such service or method 117 | // TODO ssap://com.webos.service.tvpower/power/turnOnScreen // 404 no such service or method 118 | 119 | func (tv *Tv) GetCurrentTime() (y, m, d, h, min, s int, err error) { 120 | var resp struct { 121 | Year int 122 | Month int 123 | Day int 124 | Hour int 125 | Minute int 126 | Second int 127 | } 128 | err = tv.RequestResponseParam("ssap://com.webos.service.tv.time/getCurrentTime", nil, &resp) 129 | return resp.Year, resp.Month, resp.Day, resp.Hour, resp.Minute, resp.Second, err 130 | } 131 | 132 | type CurrentSWInformation struct { 133 | ProductName string `mapstructure:"product_name"` // "product_name":"webOS" 134 | ModelName string `mapstructure:"model_name"` // "model_name":"HE_DTV_WT1M_AFAAABAA" 135 | SwType string `mapstructure:"sw_type"` // "sw_type":"FIRMWARE" 136 | MajorVer string `mapstructure:"major_ver"` // "major_ver":"05" 137 | MinorVer string `mapstructure:"minor_ver"` // "minor_ver":"05.35" 138 | Country string `mapstructure:"country"` // "country":"FI" 139 | DeviceId string `mapstructure:"device_id"` // "device_id":"3c:cd:93:7b:91:9e" 140 | AuthFlag string `mapstructure:"auth_flag"` // "auth_flag":"N" 141 | IgnoreDisable string `mapstructure:"ignore_disable"` // "ignore_disable":"N" 142 | EcoInfo string `mapstructure:"eco_info"` // "eco_info":"01" 143 | ConfigKey string `mapstructure:"config_key"` // "config_key":"00" 144 | LanguageCode string `mapstructure:"language_code"` // "language_code":"en-GB"} 145 | } 146 | 147 | func (tv *Tv) GetCurrentSWInformation() (info CurrentSWInformation, err error) { 148 | err = tv.RequestResponseParam("ssap://com.webos.service.update/getCurrentSWInformation", nil, &info) 149 | return info, err 150 | } 151 | 152 | // TODO ssap://com.webos.service.update/getProgress 153 | // TODO ssap://com.webos.service.update/getStatus 154 | // TODO ssap://com.webos.service.update/startUpdateByRemoteApp 155 | // TODO ssap://config/getConfigs // 404 no such service or method 156 | 157 | // TODO ssap://pairing/setPin 158 | // TODO ssap://settings/getSystemSettings // 404 no such service or method 159 | // TODO ssap://system/getHostMessage // 404 no such service or method 160 | 161 | type SystemInfo struct { 162 | // {"type":"response","id":"e8soy4EW","payload":{"features":{"3d":true,"dvr":true},"receiverType":"dvb","modelName":"42LB650V-ZN","returnValue":true}} 163 | Features map[string]bool 164 | ReceiverType string 165 | ModelName string 166 | } 167 | 168 | func (tv *Tv) SystemGetSystemInfo() (info SystemInfo, err error) { 169 | err = tv.RequestResponseParam("ssap://system/getSystemInfo", nil, &info) 170 | return info, err 171 | } 172 | 173 | // TODO ssap://system.notifications/createAlert // 404 no such service or method 174 | 175 | func (tv *Tv) SystemNotificationsCreateToast(msg string) (toastId string, err error) { 176 | // {"toastId":"com.webos.service.apiadapter-1522334066285","returnValue":true} 177 | var resp struct { 178 | ToastId string 179 | } 180 | err = tv.RequestResponseParam("ssap://system.notifications/createToast", 181 | Payload{"message": msg}, &resp) 182 | 183 | return resp.ToastId, err 184 | } 185 | 186 | func (tv *Tv) SystemTurnOff() (err error) { 187 | _, err = tv.Request("ssap://system/turnOff", nil) 188 | return err 189 | } 190 | 191 | // TODO ssap://timer/getSettings 192 | // TODO ssap://timer/setSettings 193 | // TODO ssap://user/resetUserInfo 194 | // TODO ssap://user/setUserData 195 | // TODO ssap://user/setUserInfo // 404 no such service or method 196 | // TODO ssap://user/setUserSchedule 197 | // TODO ssap://webapp/closeWebApp 198 | // TODO ssap://webapp/connectToApp 199 | // TODO ssap://webapp/isWebAppPinned 200 | // TODO ssap://webapp/launchWebApp 201 | // TODO ssap://webapp/pinWebApp 202 | // TODO ssap://webapp/removePinnedWebApp 203 | -------------------------------------------------------------------------------- /cmd/webostvremote/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gdamore/tcell" 6 | "github.com/inconshreveable/log15" 7 | "github.com/ogier/pflag" 8 | "github.com/rivo/tview" 9 | "github.com/snabb/webostv" 10 | "math/rand" 11 | "os" 12 | "path/filepath" 13 | "sync" 14 | "time" 15 | ) 16 | 17 | const DefaultAddress = "LGsmartTV.lan" 18 | 19 | type myError struct { 20 | where string 21 | err error 22 | } 23 | 24 | type myTv struct { 25 | *webostv.Tv 26 | errorCh chan myError 27 | } 28 | 29 | var tv myTv 30 | 31 | type myApp struct { 32 | *tview.Application 33 | 34 | wTvInfo *tvInfo 35 | wVolume *volume 36 | wHelp tview.Primitive 37 | wSelInfo *selInfo 38 | wChannels *channels 39 | wInputs *inputs 40 | wApps *apps 41 | nextFocus map[tview.Primitive]tview.Primitive 42 | 43 | logger log15.Logger 44 | } 45 | 46 | var app = myApp{ 47 | Application: tview.NewApplication(), 48 | logger: log15.Root(), 49 | } 50 | 51 | func setSelectable(widget tview.Primitive, yesno bool) { 52 | if widget, ok := widget.(interface { 53 | SetSelectable(bool, bool) *tview.Table 54 | }); ok { 55 | widget.SetSelectable(yesno, false) 56 | } 57 | } 58 | 59 | func cancelTasks(widget tview.Primitive) { 60 | if widget, ok := widget.(interface { 61 | cancelTasks() 62 | }); ok { 63 | widget.cancelTasks() 64 | } 65 | } 66 | 67 | func (app *myApp) changeFocus(currentFocus, newFocus tview.Primitive) { 68 | if currentFocus != nil { 69 | setSelectable(currentFocus, false) 70 | cancelTasks(currentFocus) 71 | } 72 | setSelectable(newFocus, true) 73 | app.SetFocus(newFocus) 74 | app.Draw() 75 | if newFocus, ok := newFocus.(interface { 76 | selectionChanged(int, int) 77 | GetSelection() (int, int) 78 | }); ok { 79 | newFocus.selectionChanged(newFocus.GetSelection()) 80 | } 81 | } 82 | 83 | func (app *myApp) inputCapture(event *tcell.EventKey) *tcell.EventKey { 84 | currentFocus := app.GetFocus() 85 | key := event.Key() 86 | switch key { 87 | case tcell.KeyTAB: 88 | if nf, ok := app.nextFocus[currentFocus]; ok { 89 | app.changeFocus(currentFocus, nf) 90 | } else { 91 | app.changeFocus(currentFocus, app.wChannels) 92 | } 93 | return nil 94 | case tcell.KeyBacktab: 95 | for k, v := range app.nextFocus { 96 | if v == currentFocus { 97 | app.changeFocus(currentFocus, k) 98 | return nil 99 | } 100 | } 101 | app.changeFocus(currentFocus, app.wVolume) 102 | return nil 103 | case tcell.KeyExit, tcell.KeyESC: 104 | app.Stop() 105 | return nil 106 | case tcell.KeyCtrlX: 107 | err := tv.SystemTurnOff() 108 | if err != nil { 109 | app.logger.Error("error turning off", "err", err) 110 | } 111 | app.Stop() 112 | return nil 113 | } 114 | if _, ok := currentFocus.(*tview.InputField); ok { 115 | return event 116 | } 117 | 118 | switch key { 119 | case tcell.KeyRune: 120 | switch event.Rune() { 121 | case 'v', 'V': 122 | app.changeFocus(currentFocus, app.wVolume) 123 | return nil 124 | case 'c', 'C': 125 | app.changeFocus(currentFocus, app.wChannels) 126 | return nil 127 | case 'i', 'I': 128 | app.changeFocus(currentFocus, app.wInputs) 129 | return nil 130 | case 'a', 'A': 131 | app.changeFocus(currentFocus, app.wApps) 132 | return nil 133 | case 'q', 'Q': 134 | app.Stop() 135 | return nil 136 | } 137 | 138 | } 139 | return event 140 | } 141 | 142 | func (app *myApp) initWidgets() { 143 | app.wTvInfo = newTvInfo() 144 | app.wVolume = newVolume() 145 | app.wHelp = newHelp() 146 | 147 | app.wSelInfo = newSelInfo() 148 | 149 | app.wChannels = newChannels() 150 | app.wChannels.updateInfo = app.wSelInfo.update 151 | 152 | app.wInputs = newInputs() 153 | app.wInputs.updateInfo = app.wSelInfo.update 154 | 155 | app.wApps = newApps() 156 | app.wApps.updateInfo = app.wSelInfo.update 157 | } 158 | 159 | func (app *myApp) initLayout() { 160 | layoutLeft := tview.NewFlex(). 161 | SetDirection(tview.FlexRow). 162 | AddItem(app.wTvInfo, 0, 2, false). 163 | AddItem(app.wVolume, 3, 0, false). 164 | AddItem(app.wHelp, 0, 2, false) 165 | 166 | layoutRight := tview.NewFlex(). 167 | SetDirection(tview.FlexRow). 168 | AddItem(tview.NewFlex(). 169 | AddItem(app.wChannels, 0, 4, false). 170 | AddItem(tview.NewFlex(). 171 | SetDirection(tview.FlexRow). 172 | AddItem(app.wInputs, 0, 1, false). 173 | AddItem(app.wApps, 0, 2, false), 0, 3, false), 0, 3, false). 174 | AddItem(app.wSelInfo, 0, 1, false) 175 | 176 | layout := tview.NewFlex(). 177 | AddItem(layoutLeft, 0, 1, false). 178 | AddItem(layoutRight, 0, 2, false) 179 | 180 | app.SetRoot(layout, true) 181 | 182 | app.nextFocus = map[tview.Primitive]tview.Primitive{ 183 | app.wChannels: app.wInputs, 184 | app.wInputs: app.wApps, 185 | app.wApps: app.wVolume, 186 | app.wVolume: app.wChannels, 187 | } 188 | } 189 | 190 | func initTv(address string) { 191 | store := openMyStore() 192 | clientKey := store.Get(address) 193 | 194 | var err error 195 | tv.Tv, err = webostv.DefaultDialer.Dial(address) 196 | if err != nil { 197 | fmt.Fprintln(os.Stderr, "TV connection error:", err) 198 | os.Exit(1) 199 | } 200 | 201 | tv.errorCh = make(chan myError, 8) 202 | go func() { 203 | err := tv.MessageHandler() 204 | tv.errorCh <- myError{"tv.MessageHandler()", err} 205 | app.Stop() 206 | }() 207 | 208 | newKey, err := tv.Register(clientKey) 209 | if err != nil { 210 | tv.Close() 211 | fmt.Fprintln(os.Stderr, "TV registration error:", err) 212 | os.Exit(1) 213 | } 214 | 215 | if newKey != clientKey { 216 | store.Set(address, newKey) 217 | } 218 | store.Close() 219 | } 220 | 221 | func main() { 222 | var err error 223 | 224 | pflag.Usage = func() { 225 | fmt.Fprintln(os.Stderr, "usage:", os.Args[0], "[OPTION]... [ADDRESS]\n") 226 | fmt.Fprintln(os.Stderr, "ADDRESS is the name or IP address of the LG WebOS TV (default: \""+DefaultAddress+"\").\n") 227 | fmt.Fprintln(os.Stderr, "The following OPTIONS are available:") 228 | pflag.PrintDefaults() 229 | } 230 | 231 | debugLog := pflag.StringP("debug", "d", "", "debug log file name") 232 | pflag.Parse() 233 | 234 | if *debugLog != "" { 235 | logHandler, err := log15.FileHandler(*debugLog, log15.LogfmtFormat()) 236 | if err != nil { 237 | fmt.Fprintln(os.Stderr, "error opening debug log:", err) 238 | os.Exit(1) 239 | } 240 | app.logger.SetHandler(logHandler) 241 | } else { 242 | app.logger.SetHandler(log15.DiscardHandler()) 243 | } 244 | 245 | var address string 246 | switch pflag.NArg() { 247 | case 0: 248 | address = DefaultAddress 249 | case 1: 250 | address = pflag.Arg(0) 251 | default: 252 | pflag.Usage() 253 | os.Exit(1) 254 | } 255 | 256 | app.logger.Debug("starting") 257 | 258 | rand.Seed(time.Now().UnixNano()) 259 | 260 | initTv(address) 261 | 262 | if *debugLog != "" { 263 | tv.SetDebug(func(str string) { 264 | app.logger.Debug(str) 265 | }) 266 | } 267 | 268 | app.initWidgets() 269 | app.initLayout() 270 | app.SetInputCapture(app.inputCapture) 271 | 272 | var wg sync.WaitGroup 273 | quit := make(chan struct{}) 274 | 275 | wg.Add(1) 276 | go func() { 277 | defer wg.Done() 278 | err := tv.AudioMonitorStatus(func(as webostv.AudioStatus) error { 279 | app.wVolume.update(as.Volume) 280 | return nil 281 | }, quit) 282 | tv.errorCh <- myError{"AudioMonitorStatus", err} 283 | app.Stop() 284 | }() 285 | 286 | wg.Add(1) 287 | go func() { 288 | defer wg.Done() 289 | err := app.wTvInfo.monitorTvCurrentInfo(quit) 290 | tv.errorCh <- myError{"monitorTvCurrentInfo", err} 291 | app.Stop() 292 | }() 293 | 294 | wg.Add(1) 295 | go func() { 296 | defer wg.Done() 297 | app.wTvInfo.updateFromTv() 298 | app.Draw() 299 | app.wChannels.updateFromTv() 300 | app.Draw() 301 | app.wInputs.updateFromTv() 302 | app.Draw() 303 | app.wApps.updateFromTv() 304 | app.Draw() 305 | // XXX check errors ? 306 | }() 307 | 308 | err = app.Run() 309 | if err != nil { 310 | fmt.Fprintln(os.Stderr, "error:", err) 311 | app.logger.Error("app.Run() returned error", "err", err) 312 | } 313 | 314 | close(quit) 315 | wg.Wait() 316 | 317 | var err2 error 318 | errorChReadLoop: 319 | for { 320 | select { 321 | case myErr := <-tv.errorCh: 322 | if myErr.err != nil { 323 | fmt.Fprintln(os.Stderr, "error in", myErr.where+":", myErr.err) 324 | app.logger.Error("error", "goroutine", myErr.where, "err", myErr.err) 325 | err2 = myErr.err 326 | } 327 | default: 328 | break errorChReadLoop 329 | } 330 | } 331 | err3 := tv.Close() 332 | if err3 != nil { 333 | fmt.Fprintln(os.Stderr, "error:", err3) 334 | app.logger.Error("tv.Close() returned error", "err", err3) 335 | } 336 | app.logger.Debug("exiting") 337 | 338 | if err != nil || err2 != nil || err3 != nil { 339 | os.Exit(1) 340 | } 341 | } 342 | 343 | func openMyStore() (store *Store) { 344 | var name string 345 | if home := os.Getenv("HOME"); home != "" { 346 | name = filepath.Join(home, ".webostv.json") 347 | } else { 348 | name = ".webostv.json" 349 | } 350 | var err error 351 | store, err = OpenStore(name) 352 | if err != nil { 353 | fmt.Fprintln(os.Stderr, err) 354 | os.Exit(1) 355 | } 356 | return store 357 | } 358 | -------------------------------------------------------------------------------- /mtv.go: -------------------------------------------------------------------------------- 1 | package webostv 2 | 3 | import ( 4 | "github.com/mitchellh/mapstructure" 5 | ) 6 | 7 | func (tv *Tv) TvChannelDown() (err error) { 8 | _, err = tv.Request("ssap://tv/channelDown", nil) 9 | return err 10 | } 11 | 12 | func (tv *Tv) TvChannelUp() (err error) { 13 | _, err = tv.Request("ssap://tv/channelUp", nil) 14 | return err 15 | } 16 | 17 | // TODO ssap://tv/getACRAuthToken // 401 insufficient permissions 18 | 19 | type TvCurrentProgramInfo struct { 20 | ProgramId string // "programId": "0_31_13105_42559", 21 | ProgramName string // "programName": "Keno ja Synttärit", 22 | Description string // "description": "Illan Keno-arvonnan [..] visailuohjelma. (2')", 23 | StartTime string // "startTime": "2018,04,03,17,58,00" 24 | EndTime string // "endTime": "2018,04,03,18,00,00", 25 | LocalStartTime string // "localStartTime": "2018,04,03,20,58,00", 26 | LocalEndTime string // "localEndTime": "2018,04,03,21,00,00", 27 | ChanelId string // "channelId": "3_32_24_24_31_13105_0", 28 | ChannelName string // "channelName": "Nelonen HD", 29 | ChannelNumber string // "channelNumber": "24", 30 | ChannelMode string // "channelMode": "Cable", 31 | Duration int // "duration": 120, 32 | } 33 | 34 | func (tv *Tv) TvGetChannelCurrentProgramInfo(channelId string) (info TvCurrentProgramInfo, err error) { 35 | var payload Payload 36 | if channelId != "" { 37 | payload = Payload{ 38 | "channelId": channelId, 39 | } 40 | 41 | } 42 | err = tv.RequestResponseParam("ssap://tv/getChannelCurrentProgramInfo", payload, &info) 43 | return info, err 44 | } 45 | 46 | type TvChannelGroupId struct { 47 | Id_ string `mapstructure:"_id"` // "_id": "7d81", 48 | Id int `mapstructure:"channelGroupId"` // "channelGroupId": 1, 49 | Name string `mapstructure:"channelGroupName"` // "channelGroupName": "DTV" 50 | } 51 | 52 | type TvChannel struct { 53 | ChannelId string // "channelId": "3_3_23_23_17_3291_0", 54 | ChannelMajMinNo string // "channelMajMinNo": "04-00023-000-003", 55 | ChannelName string // "channelName": "MTV3 HD", 56 | ChannelNumber string // "channelNumber": "23", 57 | ChannelType string // "channelType": "Cable Digital TV", 58 | ChannelTypeId int // "channelTypeId": 4, 59 | ChannelMode string // "channelMode": "Cable", 60 | ChannelModeId int // "channelModeId": 1, 61 | SignalChannelId string // "signalChannelId": "17_3291_0", 62 | ProgramId string // "programId": "17_3291_0", 63 | FavoriteGroup string // "favoriteGroup": "", 64 | SatelliteName string // "satelliteName": " ", 65 | Frequency int // "Frequency": 130000, 66 | Bandwidth int // "Bandwidth": 0, 67 | SourceIndex int // "sourceIndex": 3, 68 | ServiceType int // "serviceType": 25, 69 | ShortCut int // "shortCut": 0, 70 | Handle int // "Handle": 0, 71 | ONID int // "ONID": 0, 72 | SVCID int // "SVCID": 3291, 73 | TSID int // "TSID": 17, 74 | ConfigurationId int // "configurationId": 0, 75 | MajorNumber int // "majorNumber": 23, 76 | MinorNumber int // "minorNumber": 23, 77 | PhysicalNumber int // "physicalNumber": 3, 78 | ATV bool // "ATV": false, 79 | DTV bool // "DTV": true, 80 | Data bool // "Data": false, 81 | HDTV bool // "HDTV": true, 82 | Invisible bool // "Invisible": false, 83 | Numeric bool // "Numeric": false, 84 | PrimaryCh bool // "PrimaryCh": true, 85 | Radio bool // "Radio": false, 86 | TV bool // "TV": true, 87 | Descrambled bool // "descrambled": true, 88 | FineTuned bool // "fineTuned": false, 89 | Locked bool // "locked": false, 90 | SatelliteLcn bool // "satelliteLcn": false, 91 | Scrambled bool // "scrambled": false, 92 | Skipped bool // "skipped": false, 93 | SpecialService bool // "specialService": false 94 | GroupIdList []TvChannelGroupId 95 | // "CASystemIDList": {}, // ??? 96 | // "CASystemIDListCount": 0, // ??? 97 | } 98 | 99 | func (tv *Tv) TvGetChannelList() (list []TvChannel, err error) { 100 | var resp struct { 101 | ChannelList []TvChannel 102 | } 103 | err = tv.RequestResponseParam("ssap://tv/getChannelList", nil, &resp) 104 | return resp.ChannelList, err 105 | } 106 | 107 | type TvProgramRating struct { 108 | Id string `mapstructure:"_id"` // "_id": "157ac", 109 | RatingString string // "ratingString": "", 110 | RatingValue int // "ratingValue": 3, 111 | Region int // "region": 4606286 112 | } 113 | 114 | type TvProgram struct { 115 | ProgramId string // "programId": "0_31_13105_42559", 116 | ProgramName string // "programName": "Keno ja Synttärit", 117 | Description string // "description": "Illan Keno-arvonnan [..] visailuohjelma. (2')", 118 | StartTime string // "startTime": "2018,04,03,17,58,00" 119 | EndTime string // "endTime": "2018,04,03,18,00,00", 120 | LocalStartTime string // "localStartTime": "2018,04,03,20,58,00", 121 | LocalEndTime string // "localEndTime": "2018,04,03,21,00,00", 122 | DSTStartTime string // "DSTStartTime": "2018,04,03,20,58,00", 123 | DSTEndTime string // "DSTEndTime": "2018,04,03,21,00,00", 124 | SignalChannelId string // "signalChannelId": "31_13105_0", 125 | Duration int // "duration": 120, 126 | IsPresent bool // "isPresent": false, 127 | Rating []TvProgramRating // "rating": [ 128 | } 129 | 130 | func (tv *Tv) TvGetChannelProgramInfo(channelId string) (channel TvChannel, programlist []TvProgram, err error) { 131 | var resp struct { 132 | Channel TvChannel 133 | ProgramList []TvProgram 134 | } 135 | var payload Payload 136 | if channelId != "" { 137 | payload = Payload{ 138 | "channelId": channelId, 139 | } 140 | 141 | } 142 | err = tv.RequestResponseParam("ssap://tv/getChannelProgramInfo", payload, &resp) 143 | return resp.Channel, resp.ProgramList, err 144 | } 145 | 146 | type TvCurrentChannel struct { 147 | ChannelId string // "channelId":"3_32_24_24_31_13105_0" 148 | SignalChannelId string // "signalChannelId":"31_13105_0" 149 | ChannelModeId int // "channelModeId":1 150 | ChannelModeName string // "channelModeName":"Cable" 151 | ChannelTypeId int // "channelTypeId":4 152 | ChannelTypeName string // "channelTypeName":"Cable Digital TV" 153 | ChannelNumber string // "channelNumber":"24" 154 | ChannelName string // "channelName":"Nelonen HD" 155 | PhysicalNumber int // "physicalNumber":32 156 | IsSkipped bool // "isSkipped":false 157 | IsLocked bool // "isLocked":false 158 | IsDescrambled bool // "isDescrambled":true 159 | IsScrambled bool // "isScrambled":false 160 | IsFineTuned bool // "isFineTuned":false 161 | IsInvisible bool // "isInvisible":false 162 | // "favoriteGroup":null 163 | // "hybridtvType":null 164 | // "dualChannel":{"dualChannelId":null 165 | // "dualChannelTypeId":null 166 | // "dualChannelTypeName":null 167 | // "dualChannelNumber":null 168 | } 169 | 170 | func (tv *Tv) TvGetCurrentChannel() (cur TvCurrentChannel, err error) { 171 | err = tv.RequestResponseParam("ssap://tv/getCurrentChannel", nil, &cur) 172 | return cur, err 173 | } 174 | 175 | func (tv *Tv) TvMonitorCurrentChannel(process func(cur TvCurrentChannel) error, quit <-chan struct{}) error { 176 | return tv.MonitorStatus("ssap://tv/getCurrentChannel", nil, func(payload Payload) (err error) { 177 | var cur TvCurrentChannel 178 | err = mapstructure.Decode(payload, &cur) 179 | if err == nil { 180 | err = process(cur) 181 | } 182 | return err 183 | }, quit) 184 | } 185 | 186 | type TvExternalInput struct { 187 | Id string // "id": "SCART_1", 188 | Label string // "label": "AV1", 189 | Port int // "port": 1, 190 | AppId string // "appId": "com.webos.app.externalinput.scart", 191 | Icon string // "icon": "http://lgsmarttv.lan:3000/resources/d8dd219500f8c1604e548d980c0f60979be5b5a5/scart.png", 192 | CurrentTVStatus string // "currentTVStatus": "", 193 | Modified bool // "modified": false, 194 | Autoav bool // "autoav": false, 195 | Connected bool // "connected": false, 196 | Favorite bool // "favorite": false 197 | // "subList": [], 198 | // "subCount": 0, 199 | } 200 | 201 | func (tv *Tv) TvGetExternalInputList() (list []TvExternalInput, err error) { 202 | var resp struct { 203 | Devices []TvExternalInput 204 | } 205 | 206 | err = tv.RequestResponseParam("ssap://tv/getExternalInputList", nil, &resp) 207 | return resp.Devices, err 208 | } 209 | 210 | func (tv *Tv) TvOpenChannelId(channelId string) (err error) { 211 | _, err = tv.Request("ssap://tv/openChannel", 212 | Payload{"channelId": channelId}) 213 | return err 214 | } 215 | 216 | func (tv *Tv) TvOpenChannelNumber(channelNumber string) (err error) { 217 | _, err = tv.Request("ssap://tv/openChannel", 218 | Payload{"channelNumber": channelNumber}) 219 | return err 220 | } 221 | 222 | func (tv *Tv) TvSwitchInput(inputId string) (err error) { 223 | _, err = tv.Request("ssap://tv/switchInput", Payload{"inputId": inputId}) 224 | return err 225 | } 226 | -------------------------------------------------------------------------------- /mapps.go: -------------------------------------------------------------------------------- 1 | package webostv 2 | 3 | import ( 4 | "github.com/mitchellh/mapstructure" 5 | ) 6 | 7 | type App struct { 8 | Id string // "id": "com.webos.app.discovery", 9 | Title string // "title": "LG Store", 10 | Version string // "version": "1.0.19", 11 | Vendor string // "vendor": "LGE", 12 | FolderPath string // "folderPath": "/mnt/otncabi/usr/palm/applications/com.webos.app.discovery", 13 | DefaultWindowType string // "defaultWindowType": "card", 14 | MediumIcon string // "mediumIcon": "lgstore_80x80.png", 15 | Miniicon string // "miniicon": "http://lgsmarttv.lan:3000/r[..]e/miniicon.png", 16 | RequestedWindowOrientation string // "requestedWindowOrientation": "", 17 | LargeIcon string // "largeIcon": "lgstore_130x130.png", 18 | Icon string // "icon": "http://lgsmarttv.lan:3000/r[..]9/lgstore_80x80.png", 19 | Category string // "category": "", 20 | TrustLevel string // "trustLevel": "default", 21 | SplashBackground string // "splashBackground": "lgstore_splash.png", 22 | DeeplinkingParams string // "deeplinkingParams": "{\"contentTarget\":\"$CONTENTID\"}", 23 | RequiredEULA string // "requiredEULA": "generalTerms", 24 | Main string // "main": "index.html", 25 | Type string // "type": "web", 26 | BgImage string // "bgImage": "lgstore_preview.png", 27 | IconColor string // "iconColor": "#cf0652", 28 | ImageForRecents string // "imageForRecents": "RECENTS.png", 29 | Resolution string // "resolution": "1280x720", 30 | BgColor string // "bgColor": "#8e191b", 31 | ContainerCSS string // "containerCSS": "build/app1.css", 32 | EnyoVersion string // "enyoVersion": "2.3.0", 33 | ContainerJS string // "containerJS": "build/app1.js", 34 | Splashicon string // "splashicon": "http://lgsmarttv.lan:3000/r[..]2/splash.png", 35 | Appsize int // "appsize": 0, 36 | HardwareFeaturesNeeded int // "hardwareFeaturesNeeded": 0, 37 | Age int // "age": 0, 38 | BinId int // "binId": 361092, 39 | RequiredMemory int // "requiredMemory": 160, 40 | Lockable bool // "lockable": true, 41 | Transparent bool // "transparent": false, 42 | CheckUpdateOnLaunch bool // "checkUpdateOnLaunch": true, 43 | Launchinnewgroup bool // "launchinnewgroup": false, 44 | HandlesRelaunch bool // "handlesRelaunch": false, 45 | Inspectable bool // "inspectable": false, 46 | InAppSetting bool // "inAppSetting": false, 47 | PrivilegedJail bool // "privilegedJail": false, 48 | Visible bool // "visible": true, 49 | NoWindow bool // "noWindow": false, 50 | Removable bool // "removable": true, 51 | DisableBackHistoryAPI bool // "disableBackHistoryAPI": true 52 | InternalInstallationOnly bool // "internalInstallationOnly": true, 53 | NoSplashOnLaunch bool // "noSplashOnLaunch": true, 54 | CustomPlugin bool // "customPlugin": true, 55 | Hidden bool // "hidden": true, 56 | UIRevision interface{} // "uiRevision": 2, // "uiRevision": "2", 57 | MimeTypes []struct { // "mimeTypes": [ 58 | Mime string // "mime": "application/vnd.lge.appstore" 59 | Extension string // "extension": "html", 60 | Scheme string // "scheme": "https" 61 | Stream bool // "stream": true, 62 | } 63 | Class struct { // "class": { 64 | Hidden bool // "hidden": true 65 | } 66 | OnDeviceSource map[string]string // "onDeviceSource": { 67 | VendorExtension map[string]interface{} // // "vendorExtension": { 68 | BootLaunchParams struct { // "bootLaunchParams": { 69 | BGMode string // "BGMode": "1" 70 | Boot bool // "boot": true 71 | } 72 | // "windowGroup": { 73 | // "keyFilterTable": [ 74 | } 75 | 76 | func (tv *Tv) ApplicationManagerGetAppInfo(id string) (info App, err error) { 77 | // {"type":"response","id":"YokZ11MX","payload":{"mute":false,"returnValue":true}} 78 | var resp struct { 79 | AppInfo App 80 | AppId string 81 | } 82 | err = tv.RequestResponseParam("ssap://com.webos.applicationManager/getAppInfo", 83 | Payload{"id": id}, 84 | &resp) 85 | 86 | return resp.AppInfo, err 87 | } 88 | 89 | type ForegroundAppInfo struct { 90 | AppId string 91 | WindowId string 92 | ProcessId string 93 | } 94 | 95 | func (i *ForegroundAppInfo) IsLiveTv() bool { 96 | return i.AppId == "com.webos.app.livetv" 97 | } 98 | 99 | func (tv *Tv) ApplicationManagerGetForegroundAppInfo() (info ForegroundAppInfo, err error) { 100 | err = tv.RequestResponseParam("ssap://com.webos.applicationManager/getForegroundAppInfo", nil, &info) 101 | // {"type":"response","id":"CyvqdwSl","payload":{"appId":"com.webos.app.hdmi2","returnValue":true,"windowId":"","processId":"n-1059"}} 102 | return info, err 103 | } 104 | 105 | func (tv *Tv) ApplicationManagerMonitorForegroundAppInfo(process func(info ForegroundAppInfo) error, quit <-chan struct{}) error { 106 | return tv.MonitorStatus("ssap://com.webos.applicationManager/getForegroundAppInfo", nil, func(payload Payload) (err error) { 107 | var info ForegroundAppInfo 108 | err = mapstructure.Decode(payload, &info) 109 | if err == nil { 110 | err = process(info) 111 | } 112 | return err 113 | }, quit) 114 | } 115 | 116 | func (tv *Tv) ApplicationManagerLaunch(id string, params Payload) (processId string, err error) { 117 | // {"type":"response","id":"IkVU1ZGv","payload":{"returnValue":true,"processId":"1001"}} 118 | p := make(Payload) 119 | p["id"] = id 120 | for k, v := range params { 121 | p[k] = v 122 | } 123 | var resp struct { 124 | ProcessId string 125 | } 126 | err = tv.RequestResponseParam("ssap://com.webos.applicationManager/launch", p, &resp) 127 | 128 | return resp.ProcessId, err 129 | } 130 | 131 | func (tv *Tv) ApplicationManagerListApps() (list []App, err error) { 132 | var resp struct { 133 | Apps []App 134 | } 135 | err = tv.RequestResponseParam("ssap://com.webos.applicationManager/listApps", nil, &resp) 136 | return resp.Apps, err 137 | } 138 | 139 | type LaunchPoint struct { 140 | Removable bool // "removable": false, 141 | LargeIcon string // "largeIcon": "/mnt/otncabi/usr/palm/applications/com.webos.app.discovery/lgstore_130x130.png", 142 | Vendor string // "vendor": "LGE", 143 | Id string // "id": "com.webos.app.discovery", 144 | Title string // "title": "LG Store", 145 | BgColor string // "bgColor": "#8e191b", 146 | VendorURL string // "vendorUrl": "", 147 | IconColor string // "iconColor": "#4b4b4b", 148 | AppDescription string // "appDescription": "", 149 | Params map[string]string // "params": { // "deviceId": "HDMI_2" 150 | Version string // "version": "1.0.19", 151 | BgImage string // "bgImage": "/mnt/otncabi/usr/palm/applications/com.webos.app.discovery/lgstore_preview.png", 152 | Icon string // "icon": "http://lgsmarttv.lan:3000/resources/e1a2afa2ee2c03b7e7c89247d3425a8af8657e5d/lgstore_80x80.png", 153 | LaunchPointId string // "launchPointId": "com.webos.app.discovery_default", 154 | ImageForRecents string // "imageForRecents": "/media/cryptofs/apps/usr/palm/applications/netflix/RECENTS.png" 155 | } 156 | 157 | type CaseDetail struct { 158 | Code int // "code": 1, 159 | ServiceCountryCode string // "serviceCountryCode": "FIN", 160 | // "change": [], // ??? 161 | LocaleCode string // "localeCode": "en-GB", 162 | BroadcastCountryCode string // "broadcastCountryCode": "FIN" 163 | } 164 | 165 | func (tv *Tv) ApplicationManagerListLaunchPoints() (launchPoints []LaunchPoint, caseDetail CaseDetail, err error) { 166 | var resp struct { 167 | Subscribed bool 168 | LaunchPoints []LaunchPoint 169 | CaseDetail CaseDetail 170 | } 171 | err = tv.RequestResponseParam("ssap://com.webos.applicationManager/listLaunchPoints", nil, &resp) 172 | return resp.LaunchPoints, resp.CaseDetail, err 173 | } 174 | func (tv *Tv) SystemLauncherClose(sessionId string) (err error) { 175 | _, err = tv.Request("ssap://system.launcher/close", 176 | Payload{"sessionId": sessionId}) 177 | return err 178 | } 179 | 180 | func (tv *Tv) SystemLauncherGetAppState(sessionId string) (running, visible bool, err error) { 181 | // {"running":true,"visible":true,"returnValue":true} 182 | var resp struct { 183 | Running bool 184 | Visible bool 185 | } 186 | err = tv.RequestResponseParam("ssap://system.launcher/getAppState", 187 | Payload{"sessionId": sessionId}, &resp) 188 | 189 | return resp.Running, resp.Visible, err 190 | } 191 | 192 | func (tv *Tv) SystemLauncherLaunch(appId string, params Payload) (sessionId string, err error) { 193 | // {"returnValue":true,"sessionId":"eW91dHViZS5sZWFuYmFjay52NDp1bmRlZmluZWQ="} 194 | p := make(Payload) 195 | p["id"] = appId 196 | for k, v := range params { 197 | p[k] = v 198 | } 199 | var resp struct { 200 | SessionId string 201 | } 202 | err = tv.RequestResponseParam("ssap://system.launcher/launch", p, &resp) 203 | 204 | return resp.SessionId, err 205 | } 206 | 207 | func (tv *Tv) SystemLauncherOpen(url string) (appId, sessionId string, err error) { 208 | // {"returnValue":true,"id":"com.webos.app.browser","sessionId":"Y29tLndlYm9zLmFwcC5icm93c2VyOnVuZGVmaW5lZA=="} 209 | var resp struct { 210 | Id string 211 | SessionId string 212 | } 213 | err = tv.RequestResponseParam("ssap://system.launcher/open", 214 | Payload{"target": url}, &resp) 215 | 216 | return resp.Id, resp.SessionId, err 217 | } 218 | 219 | /* 220 | func (tv *Tv) LaunchBrowser(url string) (sessionId string, err error) { 221 | var p Payload 222 | if url != "" { 223 | p = Payload{"target": url} 224 | } 225 | return tv.SystemLauncherLaunch("com.webos.app.browser", p) 226 | } 227 | */ 228 | 229 | func (tv *Tv) LaunchYoutube(videoId string) (sessionId string, err error) { 230 | var p Payload 231 | if videoId != "" { 232 | p = Payload{ 233 | "params": Payload{ 234 | "contentTarget": "http://www.youtube.com/tv?v=" + videoId, 235 | }, 236 | } 237 | } 238 | return tv.SystemLauncherLaunch("youtube.leanback.v4", p) 239 | } 240 | 241 | func (tv *Tv) LaunchNetflix(contentId string) (sessionId string, err error) { 242 | var p Payload 243 | if contentId != "" { 244 | p = Payload{ 245 | "contentId": "m=http%3A%2F%2Fapi.netflix.com%2Fcatalog%2Ftitles%2Fmovies%2F" + contentId + "&source_type=4", 246 | } 247 | } 248 | return tv.SystemLauncherLaunch("netflix", p) 249 | } 250 | -------------------------------------------------------------------------------- /api.go: -------------------------------------------------------------------------------- 1 | package webostv 2 | 3 | import ( 4 | "crypto/tls" 5 | "encoding/json" 6 | "github.com/gorilla/websocket" 7 | "github.com/mitchellh/mapstructure" 8 | "github.com/pkg/errors" 9 | "math/rand" 10 | "net" 11 | "net/http" 12 | "sync" 13 | "time" 14 | ) 15 | 16 | var ( 17 | Timeout = time.Second * 5 18 | RegisterTimeout = time.Second * 30 19 | ErrTimeout = errors.New("timeout") 20 | ErrNoResponse = errors.New("no response") 21 | ErrRegistrationFailed = errors.New("registration failed") 22 | ) 23 | 24 | type Tv struct { 25 | Address string 26 | ws *websocket.Conn 27 | wsWriteMutex sync.Mutex 28 | respCh map[string]chan<- Msg 29 | respChMutex sync.Mutex 30 | debugFunc func(string) 31 | } 32 | 33 | type Dialer struct { 34 | DisableTLS bool 35 | WebsocketDialer *websocket.Dialer 36 | } 37 | 38 | var DefaultDialer = Dialer{ 39 | DisableTLS: false, 40 | WebsocketDialer: &websocket.Dialer{ 41 | Proxy: http.ProxyFromEnvironment, 42 | HandshakeTimeout: 10 * time.Second, 43 | TLSClientConfig: &tls.Config{ 44 | InsecureSkipVerify: true, // TV has a self signed certificate 45 | }, 46 | NetDial: (&net.Dialer{ 47 | Timeout: time.Second * 5, 48 | KeepAlive: time.Second * 30, // ensure we notice if the TV goes away 49 | }).Dial, 50 | }, 51 | } 52 | 53 | func (dialer *Dialer) Dial(address string) (tv *Tv, err error) { 54 | var url string 55 | if dialer.DisableTLS { 56 | url = "ws://" + address + ":3000" 57 | } else { 58 | url = "wss://" + address + ":3001" 59 | } 60 | wsDialer := dialer.WebsocketDialer 61 | if wsDialer == nil { 62 | wsDialer = websocket.DefaultDialer 63 | } 64 | ws, resp, err := wsDialer.Dial(url, nil) 65 | if err != nil { 66 | return nil, err 67 | } 68 | err = resp.Body.Close() 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | return &Tv{ 74 | Address: address, 75 | ws: ws, 76 | }, nil 77 | } 78 | 79 | func (tv *Tv) debug(str string, buf []byte) { 80 | if tv.debugFunc != nil { 81 | if buf != nil { 82 | tv.debugFunc(str + string(buf)) 83 | } else { 84 | tv.debugFunc(str) 85 | } 86 | } 87 | } 88 | 89 | func (tv *Tv) SetDebug(debugFunc func(string)) { 90 | tv.debugFunc = debugFunc 91 | } 92 | 93 | func helloPayload() Payload { 94 | return Payload{ 95 | "forcePairing": false, 96 | "pairingType": "PROMPT", 97 | "manifest": map[string]interface{}{ 98 | "manifestVersion": 1, 99 | "appVersion": "1.1", 100 | "signed": map[string]interface{}{ 101 | "created": "20140509", 102 | "appId": "com.lge.test", 103 | "vendorId": "com.lge", 104 | "localizedAppNames": map[string]string{ 105 | "": "LG Remote App", 106 | "ko-KR": "리모컨 앱", 107 | "zxx-XX": "ЛГ Rэмotэ AПП", 108 | }, 109 | "localizedVendorNames": map[string]string{ 110 | "": "LG Electronics", 111 | }, 112 | "permissions": []string{ 113 | "TEST_SECURE", 114 | "CONTROL_INPUT_TEXT", 115 | "CONTROL_MOUSE_AND_KEYBOARD", 116 | "READ_INSTALLED_APPS", 117 | "READ_LGE_SDX", 118 | "READ_NOTIFICATIONS", 119 | "SEARCH", 120 | "WRITE_SETTINGS", 121 | "WRITE_NOTIFICATION_ALERT", 122 | "CONTROL_POWER", 123 | "READ_CURRENT_CHANNEL", 124 | "READ_RUNNING_APPS", 125 | "READ_UPDATE_INFO", 126 | "UPDATE_FROM_REMOTE_APP", 127 | "READ_LGE_TV_INPUT_EVENTS", 128 | "READ_TV_CURRENT_TIME", 129 | }, 130 | "serial": "2f930e2d2cfe083771f68e4fe7bb07", 131 | }, 132 | "permissions": []string{ 133 | "LAUNCH", 134 | "LAUNCH_WEBAPP", 135 | "APP_TO_APP", 136 | "CLOSE", 137 | "TEST_OPEN", 138 | "TEST_PROTECTED", 139 | "CONTROL_AUDIO", 140 | "CONTROL_DISPLAY", 141 | "CONTROL_INPUT_JOYSTICK", 142 | "CONTROL_INPUT_MEDIA_RECORDING", 143 | "CONTROL_INPUT_MEDIA_PLAYBACK", 144 | "CONTROL_INPUT_TV", 145 | "CONTROL_POWER", 146 | "READ_APP_STATUS", 147 | "READ_CURRENT_CHANNEL", 148 | "READ_INPUT_DEVICE_LIST", 149 | "READ_NETWORK_STATE", 150 | "READ_RUNNING_APPS", 151 | "READ_TV_CHANNEL_LIST", 152 | "WRITE_NOTIFICATION_TOAST", 153 | "READ_POWER_STATE", 154 | "READ_COUNTRY_INFO", 155 | }, 156 | "signatures": []map[string]interface{}{ 157 | map[string]interface{}{ 158 | "signatureVersion": 1, 159 | "signature": "eyJhbGdvcml0aG0iOiJSU0EtU0hBMjU2Iiwia2V5SWQiOiJ0ZXN0LXNpZ25pbmctY2VydCIsInNpZ25hdHVyZVZlcnNpb24iOjF9.hrVRgjCwXVvE2OOSpDZ58hR+59aFNwYDyjQgKk3auukd7pcegmE2CzPCa0bJ0ZsRAcKkCTJrWo5iDzNhMBWRyaMOv5zWSrthlf7G128qvIlpMT0YNY+n/FaOHE73uLrS/g7swl3/qH/BGFG2Hu4RlL48eb3lLKqTt2xKHdCs6Cd4RMfJPYnzgvI4BNrFUKsjkcu+WD4OO2A27Pq1n50cMchmcaXadJhGrOqH5YmHdOCj5NSHzJYrsW0HPlpuAx/ECMeIZYDh6RMqaFM2DXzdKX9NmmyqzJ3o/0lkk/N97gfVRLW5hA29yeAwaCViZNCP8iC9aO0q9fQojoa7NQnAtw==", 160 | }, 161 | }, 162 | }, 163 | } 164 | } 165 | 166 | func (tv *Tv) Register(key string) (newKey string, err error) { 167 | helloMsg := Msg{ 168 | Type: "register", 169 | Id: makeId(), 170 | Payload: helloPayload(), 171 | } 172 | ch := make(chan Msg, 1) 173 | tv.registerRespCh(helloMsg.Id, ch) 174 | defer tv.unregisterRespCh(helloMsg.Id) 175 | 176 | if key != "" { 177 | helloMsg.Payload["client-key"] = key 178 | } 179 | 180 | err = tv.writeJSON(&helloMsg) 181 | if err != nil { 182 | return "", err 183 | } 184 | 185 | var respMsg Msg 186 | var ok bool 187 | 188 | for { 189 | select { 190 | case respMsg, ok = <-ch: 191 | if !ok { 192 | return "", ErrNoResponse 193 | } 194 | err = checkResponse(respMsg) 195 | if err != nil { 196 | return "", err 197 | } 198 | 199 | case <-time.After(RegisterTimeout): 200 | return "", ErrTimeout 201 | } 202 | if respMsg.Type != "response" { 203 | break 204 | } 205 | } 206 | if respMsg.Type != "registered" { 207 | return "", ErrRegistrationFailed 208 | } 209 | if tmp, ok := respMsg.Payload["client-key"]; ok { 210 | tmp, ok := tmp.(string) 211 | if !ok { 212 | return "", errors.New("client-key from TV is not a string") 213 | } 214 | newKey = tmp 215 | } 216 | return newKey, nil 217 | } 218 | 219 | func (tv *Tv) writeJSON(v interface{}) error { 220 | buf, err := json.Marshal(v) 221 | if err != nil { 222 | return errors.Wrap(err, "JSON marshal error") 223 | } 224 | tv.debug("write: ", buf) 225 | tv.wsWriteMutex.Lock() 226 | err = tv.ws.WriteMessage(websocket.TextMessage, buf) 227 | tv.wsWriteMutex.Unlock() 228 | if err != nil { 229 | return errors.Wrap(err, "websocket write error") 230 | } 231 | return nil 232 | } 233 | 234 | func (tv *Tv) registerRespCh(id string, ch chan<- Msg) { 235 | tv.respChMutex.Lock() 236 | if tv.respCh == nil { 237 | tv.respCh = make(map[string]chan<- Msg) 238 | } 239 | tv.respCh[id] = ch 240 | tv.respChMutex.Unlock() 241 | } 242 | 243 | func (tv *Tv) unregisterRespCh(id string) { 244 | tv.respChMutex.Lock() 245 | if ch, ok := tv.respCh[id]; ok { 246 | close(ch) 247 | delete(tv.respCh, id) 248 | } 249 | tv.respChMutex.Unlock() 250 | } 251 | 252 | func (tv *Tv) Close() (err error) { 253 | err = tv.ws.Close() 254 | return err 255 | } 256 | 257 | type Payload map[string]interface{} 258 | 259 | func (p *Payload) UnmarshalJSON(b []byte) (err error) { 260 | var tmp map[string]interface{} 261 | err = json.Unmarshal(b, &tmp) 262 | if err == nil { 263 | *p = tmp 264 | } else { 265 | if _, ok := err.(*json.UnmarshalTypeError); ok { 266 | err = nil 267 | *p = nil 268 | } 269 | } 270 | return err 271 | } 272 | 273 | type Msg struct { 274 | Type string `json:"type,omitempty"` 275 | Id string `json:"id,omitempty"` 276 | Uri string `json:"uri,omitempty"` 277 | Payload Payload `json:"payload,omitempty"` 278 | Error string `json:"error,omitempty"` 279 | } 280 | 281 | func (tv *Tv) MessageHandler() (err error) { 282 | defer func() { 283 | // close the channels to indicate that the reader is exiting 284 | tv.respChMutex.Lock() 285 | for _, ch := range tv.respCh { 286 | close(ch) 287 | } 288 | tv.respCh = nil 289 | tv.respChMutex.Unlock() 290 | }() 291 | 292 | for { 293 | messageType, p, err := tv.ws.ReadMessage() 294 | if err != nil { 295 | return err 296 | } 297 | tv.debug("read: ", p) 298 | if messageType != websocket.TextMessage { 299 | tv.debug("non-text message type, ignored", nil) 300 | continue 301 | } 302 | var msg Msg 303 | err = json.Unmarshal(p, &msg) 304 | if err != nil { 305 | tv.debug("invalid json in message, ignored", nil) 306 | continue 307 | } 308 | tv.respChMutex.Lock() 309 | ch := tv.respCh[msg.Id] 310 | tv.respChMutex.Unlock() 311 | ch <- msg 312 | } 313 | // not reached 314 | } 315 | 316 | func (tv *Tv) RequestResponseParam(uri string, req Payload, resp interface{}) (err error) { 317 | r, err := tv.Request(uri, req) 318 | if err != nil { 319 | return err 320 | } 321 | return mapstructure.Decode(r, resp) 322 | } 323 | 324 | func (tv *Tv) Request(uri string, req Payload) (resp Payload, err error) { 325 | var msg Msg 326 | msg.Type = "request" 327 | msg.Id = makeId() 328 | msg.Uri = uri 329 | msg.Payload = req 330 | 331 | ch := make(chan Msg, 1) 332 | tv.registerRespCh(msg.Id, ch) 333 | defer tv.unregisterRespCh(msg.Id) 334 | 335 | err = tv.writeJSON(&msg) 336 | if err != nil { 337 | return nil, err 338 | } 339 | select { 340 | case respMsg, ok := <-ch: 341 | if !ok { 342 | return nil, ErrNoResponse 343 | } 344 | err = checkResponse(respMsg) 345 | return respMsg.Payload, err 346 | case <-time.After(Timeout): 347 | return nil, ErrTimeout 348 | } 349 | } 350 | 351 | func checkResponse(r Msg) (err error) { 352 | switch r.Type { 353 | case "error": 354 | var err2 error 355 | if _, ok := r.Payload["returnValue"]; ok { 356 | err2 = checkPayloadReturnValue(r.Payload) 357 | } 358 | if err2 == nil { 359 | return errors.Errorf("API error: %s", r.Error) 360 | } 361 | return errors.Errorf("API error: %s - %s", r.Error, err2) 362 | case "response": 363 | return checkPayloadReturnValue(r.Payload) 364 | case "registered": 365 | if r.Payload == nil { 366 | return errors.New("nil payload") 367 | } 368 | return nil 369 | default: 370 | return errors.Errorf("unexpeced API response type: %s", r.Type) 371 | } 372 | } 373 | 374 | func checkPayloadReturnValue(p Payload) (err error) { 375 | if p == nil { 376 | return errors.New("nil payload") 377 | } 378 | returnValueI, ok := p["returnValue"] 379 | if !ok { 380 | return errors.New("returnValue missing") 381 | } 382 | 383 | returnValue, ok := returnValueI.(bool) 384 | if !ok { 385 | return errors.New("returnValue type is not bool") 386 | } 387 | if !returnValue { 388 | if p["errorCode"] != nil { 389 | return errors.Errorf("error %v: %v", p["errorCode"], p["errorText"]) 390 | } else { 391 | return errors.New("returnValue: false, errorCode: nil") 392 | } 393 | } 394 | return nil 395 | } 396 | 397 | func (tv *Tv) Subscribe(uri string, req Payload, msgCh chan<- Msg) (id string, err error) { 398 | var msg Msg 399 | msg.Type = "subscribe" 400 | msg.Id = makeId() 401 | msg.Uri = uri 402 | msg.Payload = req 403 | 404 | tv.registerRespCh(msg.Id, msgCh) 405 | 406 | err = tv.writeJSON(&msg) 407 | if err != nil { 408 | tv.unregisterRespCh(msg.Id) 409 | return "", err 410 | } 411 | return msg.Id, nil 412 | } 413 | 414 | func (tv *Tv) Unsubscribe(uri string, id string, req Payload) error { 415 | var msg Msg 416 | msg.Type = "unsubscribe" 417 | msg.Id = id 418 | msg.Uri = uri 419 | msg.Payload = req 420 | 421 | tv.unregisterRespCh(msg.Id) 422 | 423 | return tv.writeJSON(&msg) 424 | } 425 | 426 | func (tv *Tv) MonitorStatus(uri string, req Payload, processPayload func(Payload) error, quit <-chan struct{}) (err error) { 427 | msgCh := make(chan Msg, 1) 428 | 429 | id, err := tv.Subscribe(uri, req, msgCh) 430 | if err != nil { 431 | return err 432 | } 433 | defer tv.Unsubscribe(uri, id, nil) 434 | 435 | for { 436 | select { 437 | case msg, ok := <-msgCh: 438 | if !ok { 439 | return nil 440 | } 441 | if msg.Payload == nil { 442 | continue 443 | } 444 | err = processPayload(msg.Payload) 445 | if err != nil { 446 | return err 447 | } 448 | case <-quit: 449 | return nil 450 | } 451 | } 452 | // not reached 453 | } 454 | 455 | func makeId() string { 456 | return randSeq(8) 457 | } 458 | 459 | var randSeqLetters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") 460 | 461 | func randSeq(n int) string { 462 | b := make([]rune, n) 463 | for i := range b { 464 | b[i] = randSeqLetters[rand.Intn(len(randSeqLetters))] 465 | } 466 | return string(b) 467 | } 468 | --------------------------------------------------------------------------------