Skip to content

Instantly share code, notes, and snippets.

@bazzargh
Last active July 15, 2022 09:51
Show Gist options
  • Save bazzargh/fcb19fbcc814294c0fb59faa568c04f0 to your computer and use it in GitHub Desktop.
Save bazzargh/fcb19fbcc814294c0fb59faa568c04f0 to your computer and use it in GitHub Desktop.
discover tv control url for panasonic viera
#!/usr/bin/env ruby
require 'socket'
require 'net/http'
require 'pstore'
require 'rexml/document'
require "curses"
class Television
COMMAND_URN = "urn:panasonic-com:service:p00NetworkControl:1"
def self.command(*args)
tv = Television.new
if args.length == 0
tv.cmd_controller
elsif args.length == 1 && args[0] =~ /\A\d+\Z/
tv.cmd_channel(*args)
elsif tv.respond_to?("cmd_#{args[0]}")
tv.send("cmd_#{args[0]}", *(args[1..-1]))
elsif tv.alias?(:alias, *args)
tv.select_channel(tv.lookup_alias(:alias, *args)[1])
elsif tv.alias?(:channel, *args)
tv.select_channel(tv.lookup_alias(:channel, *args)[1])
elsif tv.alias?(:app, *args)
tv.launch(tv.lookup_alias(:app, *args)[0])
else
tv.cmd_help
end
end
def launch(scope, name)
value = pstore.transaction do
pstore[scope] ||= {}
value = pstore[scope][name]
end
if :channel == scope
select_channel(value)
else
select_app(value)
end
end
def select_channel(digits)
digits.to_s.chars.each do |d|
send_key(d)
end
end
def select_app(product_id)
send_command("X_LaunchApp", "<X_AppType>vc_app</X_AppType><X_LaunchKeyword>#{product_id}</X_LaunchKeyword>")
end
def cmd_channels
channels.each do |k,v|
puts "#{v}. #{k}"
end
end
def cmd_controller
Controller.new(self).run
end
def alias?(scope, *args)
!!self.lookup_alias(scope, *args)
end
def lookup_alias(scope, *args)
seek = channel_alias(*args)
puts seek
pstore.transaction do
pstore[scope] ||= {}
r = pstore[scope].find { |k,v| f = channel_alias(k); puts "#{f.inspect} #{seek.inspect}"; f == seek }
if r
puts "FOUND #{r}"
end
r
end
end
def cmd_learn(num, *args)
fail "Invalid channel #{num.inspect}" unless num.to_s =~ /\A\d+\Z/
learn_alias(:alias, num.to_s, *args)
end
def channel_alias(*args)
args.join("").downcase.gsub(/[^a-z0-9]/, "")
end
def cmd_help(*args)
puts <<USAGE
tv discover # find the tv url and its apps
tv # onscreen controller emulator
tv controller # ditto
tv channel 123 # switches to channel 123
tv 123 # just a number does the same thing
tv learn 707 radio 6 # learns radio 6 as an alias for 707
tv radio 6 # switches to 707, if this was learned
tv netflix # turns on netflix. works for other discovered apps too
tv type foo # in netflix, searches using the ridiculous onscreen keyboard
tv search x # find x
USAGE
end
def search(*args)
target = args.join(" ").downcase
target_words = target.split(' ').uniq
candidates = pstore.transaction do
a = (pstore[:app] || {}).keys.map {|n| [:app, n]}
c = (pstore[:channel] || {}).keys.map {|n| [:channel, n]}
a + c
end
sortable = candidates.map do |type, orig|
name = orig.downcase
words = name.split(" ").uniq
score = if name == target
1000
elsif name.start_with?(target)
500
else
target_words.map do |w|
if words.include? w
100
elsif words.any? {|t| t.start_with?(w)}
10
elsif words.any? {|t| t.include?(w)}
1
end
end.map(&:to_i).reduce(0, :+)
end
[-score, name, type, orig]
end.reject { |s,_,_,_| s == 0 }
sortable.sort.map {|_, _, type, name| [type, name]}
end
def cmd_channel(num, *args)
fail "Invalid channel #{num.to_s.inspect}" unless num.to_s =~ /\A\d+\Z/
puts "switching to channel #{num.to_s}"
select_channel(num)
end
def send_key(c, delay=0)
if (c == 'NETFLIX')
launch(:app, 'netflix')
elsif '0' <= c && c <= '9'
send_command("X_SendKey", "<X_KeyEvent>NRC_D#{c}-ONOFF</X_KeyEvent>")
else
send_command('X_SendKey', "<X_KeyEvent>NRC_#{c}-ONOFF</X_KeyEvent>")
end
sleep delay if delay
end
def cmd_string(*args)
send_command('X_SendString', "<X_String>#{args.join(" ")}</X_String>")
end
def cmd_type(*args)
send_netflix_letters(args.join(' '))
end
def learn_alias(scope, value, *name)
pstore.transaction do
pstore[scope] ||= {}
pstore[scope][channel_alias(name)] = value
puts "Learned #{scope} '#{name.join(" ")}' is #{value}"
end
end
def cmd_discover(*args)
pstore.transaction do
pstore[:tv_uri] = panasonic_uri
puts "tv_uri is now #{pstore[:tv_uri]}"
pstore[:app] = {}
end
resp = send_command('X_GetAppList')
xml = REXML::Document.new(resp.body)
pstore.transaction do
xml.elements.each("//X_AppList") do |elt|
data = elt.text.split('>').map {|r| r.split("'")}
data.each do |app|
pstore[:app][app[3]] = app[2]
end
end
pstore[:channel] = {}
channels.each do |k, v|
pstore[:channel][k] = v
end
end
end
def send_command(action, command = '')
#puts "Sending #{action} #{command}"
body = [
"<?xml version='1.0' encoding='utf-8'?>",
"<soap:Envelope xmlns:soap='http://schemas.xmlsoap.org/soap/envelope/' soap:encodingStyle='http://schemas.xmlsoap.org/soap/encoding/'>",
"<soap:Body>",
"<ns:#{action} xmlns:ns='#{COMMAND_URN}'>#{command}</ns:#{action}>",
"</soap:Body>",
"</soap:Envelope>"
].join("\r\n")
if ENV['DEBUG'] == '1'
puts "UPnP request:"
puts body
puts
end
Net::HTTP.start(tv_uri.host, tv_uri.port) do |http|
resp = http.post("/nrc/control_0", body, {"Content-type" => 'text/xml; charset="utf-8"', "SOAPACTION" => "\"#{COMMAND_URN}\##{action}\""})
if ENV['DEBUG'] == '1'
puts "UPnP response:"
puts resp.code
puts resp.to_hash.inspect
puts resp.body
puts
end
resp
end
end
def tv_uri
@tv_uri ||= pstore.transaction do
URI(pstore[:tv_uri])
end
end
def panasonic_uri
mcast = "239.255.255.250"
port = 1900
socket = UDPSocket.new
socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, [1].pack('i'))
socket.setsockopt(Socket::IPPROTO_IP, Socket::IP_TTL, [2].pack('i'))
msg = ["M-SEARCH * HTTP/1.1", "HOST: #{mcast}:#{port}", 'MAN: "ssdp:discover"', "MX: 1", "ST: #{COMMAND_URN}", ""].join("\r\n")
if ENV['DEBUG']
puts "SSDP message:"
puts msg
puts
end
socket.send(msg, 0, mcast, port)
data,_ = socket.recvfrom(1024)
if ENV['DEBUG']
puts data
end
match = /LOCATION:\s*(\S+)/im.match(data)
if match
puts "SSDP response:"
return match.captures[0]
puts
end
ensure
socket.close
end
def send_netflix_letters(str)
send_key("RETURN", 2)
send_key("ENTER", 2)
col = 0
row = 1
str = str.downcase.gsub(/[^a-z0-9 ]+/, "")
str.chars.each do |c|
row, col = seek_char(row, col, c)
send_key('ENTER', 1)
end
seek_char(row, col, 'f')
end
def seek_char(row, col, c)
keypad = [
[" ", "DEL"],
%w(a b c d e f),
%w(g h i j k l),
%w(m n o p q r),
%w(s t u v w x),
%w(y z 1 2 3 4),
%w(5 6 7 8 9 0)
]
new_row = keypad.find_index {|r| r.include?(c)}
new_col = keypad[new_row].find_index(c)
# must be left, up, down, right order to handle space correctly
puts "Seeking #{c} from #{row} #{col}"
while col > new_col
send_key('LEFT', 1)
col -= 1
end
while row > new_row
send_key('UP', 1)
row -= 1
end
while row < new_row
send_key('DOWN', 1)
row += 1
end
while col < new_col
send_key('RIGHT', 1)
col += 1
end
return row, col
end
def pstore
@pstore ||= PStore.new(File.join(Dir.home, ".panasonic-viera"))
end
def channels
{
"BBC One" => 1,
"BBC Two" => 2,
"ITV1" => 3,
"Channel 4" => 4,
"Channel 5" => 5,
"ITV2" => 6,
"BBC Alba" => 7,
"Glasgow" => 8,
"BBC Four" => 9,
"ITV3" => 10,
"Pick TV" => 11,
"Dave" => 12,
"Channel 4 +1" => 13,
"More 4" => 14,
"Film 4" => 15,
"QVC" => 16,
"Really" => 17,
"4Music" => 18,
"Yesterday" => 19,
"Drama" => 20,
"5 USA" => 21,
"ITV4" => 24,
"Home" => 25,
"ITVBe" => 26,
"ITV2 +1" => 27,
"E4" => 28,
"E4 +1" => 29,
"5 Star" => 30,
"Spike" => 31,
"Movie Mix" => 32,
"ITV1 +1" => 33,
"ITV3 +1" => 34,
"QVC Beauty" => 35,
"Create & Craft" => 36,
"Quest" => 37,
"Quest + 1" => 38,
"The Store" => 39,
"Rocks & Co 1" => 40,
"Food Network" => 41,
"Travel Channel" => 42,
"Gems TV" => 43,
"Channel 5 + 1" => 44,
"Film 4 +1" => 45,
"Challenge" => 46,
"4seven" => 47,
"Movies4Men" => 48,
"Jewellery Channel" => 49,
"Channel 5 + 24" => 55,
"Viva" => 57,
"ITVBe +1" => 58,
"BT Sport Showcase" => 59,
"True Entertainment" => 61,
"ITV4 + 1" => 62,
"Community Channel" => 63,
"CBS Action" => 64,
"TBN UK" => 65,
"CBS Reality" => 66,
"CBS Reality +1" => 67,
"Tru TV" => 68,
"Tru TV +1" => 69,
"Horror Channel" => 70,
"YourTV" => 72,
"YourTV +1" => 73,
"CBS Drama" => 74,
"Jewellery Maker" => 76,
"Rishtey Europe" => 77,
"Talking Pictures TV" => 81,
"5USA +1" => 83,
"Dave ja vu" => 84,
"Freeview Information" => 100,
"BBC One HD" => 101,
"BBC Two HD" => 102,
"ITV1 HD / STV HD" => 103,
"Channel 4 HD" => 104,
"BBC Three HD" => 105,
"BBC Four HD" => 106,
"BBC News HD" => 107,
"Al Jazeera English HD" => 108,
"Channel 4 +1 HD" => 109,
"4seven HD" => 110,
"QVC +1 HD" => 111,
"QVC Beauty HD" => 112,
"Russia Today UK HD" => 113,
"CBBC" => 120,
"CBeebies" => 121,
"CITV" => 122,
"CBBC HD" => 123,
"CBbeebies HD" => 124,
"POP" => 125,
"Tiny Pop" => 126,
"Kix" => 127,
"BBC News" => 130,
"BBC Parliament" => 131,
"Sky News" => 132,
"Al Jazeera Arabic" => 134,
"Russia Today" => 135,
"BBC Radio 1" => 700,
"BBC 1 Xtra" => 701,
"BBC Radio 2" => 702,
"BBC Radio 3" => 703,
"BBC Radio 4" => 704,
"BBC 5 Live" => 705,
"BBC 5 Live Sports Extra" => 706,
"BBC 6 Music" => 707,
"BBC Radio 4 Extra" => 708,
"BBC Asian Network" => 709,
"BBC World Service" => 710,
"The Hits Radio" => 711,
"KissFresh" => 712,
"Kiss" => 713,
"Kisstory" => 714,
"Magic" => 715,
"Heat" => 716,
"Kerrang!" => 717,
"Smooth Radio" => 718,
"Radio Scotland" => 719,
"Gaelic" => 720,
"TalkSPORT" => 723,
"Capital FM" => 724,
"Premier Christian Radio" => 725,
"U105 Radio" => 726,
"Absolute Radio" => 727,
"Heart" => 728,
"RNIB Connect" => 730,
"Classic FM" => 731,
"LBC" => 732,
"Trans World Radio" => 733
}
end
end
class Controller
include Curses
DISPLAYMAP = {
"POWER" => [2, 2, "[Q]"],
"NETFLIX" => [3, 2, "[NETFLIX]"],
"HOME" => [3, 11, "[HOME]"],
"UP" => [4, 7, "[^]"],
"CANCEL" => [4, 12, "[ESC]"],
"LEFT" => [5, 2, "[<]"],
"ENTER" => [5, 5, "[ENTER]"],
"RIGHT" => [5, 12, "[>]"],
"DOWN" => [6, 7, "[v]"],
"RETURN" => [6, 12, "[DEL]"],
"RED" => [7, 2, "[R]"],
"GREEN" => [7, 6, "[G]"],
"YELLOW" => [7, 10, "[Y]"],
"BLUE" => [7, 14, "[B]"],
"VOLUP" => [8, 4, "[+]"],
"CH_UP" => [8, 12, "[^]"],
"MUTE" => [9, 7, "[MUTE]"],
"VOLDOWN" => [10, 4, "[-]"],
"CH_DOWN" => [10, 12, "[v]"],
"1" => [11, 3, "[1]"],
"2" => [11, 8, "[2]"],
"3" => [11, 13, "[3]"],
"4" => [12, 3, "[4]"],
"5" => [12, 8, "[5]"],
"6" => [12, 13, "[6]"],
"7" => [13, 3, "[7]"],
"8" => [13, 8, "[8]"],
"9" => [13, 13, "[9]"],
"0" => [14, 8, "[0]"]
}
KEYMAP = {
"Q" => "POWER",
9 => "NETFLIX",
13 => "ENTER",
Curses::Key::UP => "UP",
Curses::Key::DOWN => "DOWN",
Curses::Key::LEFT => "LEFT",
Curses::Key::RIGHT => "RIGHT",
" " => "MUTE",
"r" => "RED",
"g" => "GREEN",
"y" => "YELLOW",
"b" => "BLUE",
"=" => "VOLUP",
"+" => "VOLUP",
"-" => "VOLDOWN",
"_" => "VOLDOWN",
"\\" => "HOME",
27 => "CANCEL",
Curses::Key::BACKSPACE=> "CANCEL",
127 => "RETURN",
"]" => "CH_UP",
"[" => "CH_DOWN",
"?" => "SEARCH"
}
def initialize(tv)
@tv = tv
end
def init_layout
screen = <<'END'
_________________ +------------------------------+
/ \ Press ? to search -> | |
| | Q is power off only +------------------------------+
| | NETFLIX is TAB | |
| | HOME is \ | |
| | | |
| | | |
| | | |
| | CH_UP is ] | |
| | MUTE is SPACE | |
| | CH_DOWN is [ | |
| | | |
| | | |
| | +------------------------------+
| |
| |
| Panasonic |
| T V |
\_________________/ Ctrl-c to exit
END
row = 0
screen.lines.each do |line|
setpos(row, 0)
addstr(line)
row += 1
end
DISPLAYMAP.each do |k, _|
display_button(k)
end
end
def handle_key(k)
action = KEYMAP[k]
debug(k.inspect)
if k.is_a?(String) && "0" <= k && k <= "9"
action = k
end
return unless action
if action == "SEARCH"
do_search
else
# perform keypress
display_button(action, Curses::A_REVERSE)
@tv.send_key(action)
display_button(action)
end
end
def do_search
selected = 0
results = []
search = ""
maxlen = 30
display_search(search, selected, results, maxlen)
loop do
ch = getch
debug(ch.inspect)
if ch.is_a?(String) && /[a-zA-Z0-9. +-]/=~ch
search = "#{search}#{ch}"[0, maxlen]
elsif ch == 127
search = "#{search[0...-1]}"
elsif ch == Curses::Key::UP
selected = [0, selected - 1].max
elsif ch == Curses::Key::DOWN
selected = [selected + 1, 9].min
elsif ch == 13
selection = results[selected]
debug(selection.inspect)
@tv.launch(*selection) if selection
display_search("", 0, [], maxlen)
break
end
results = @tv.search(search).first(10)
display_search(search, selected, results, maxlen)
end
end
def debug(str)
Curses.setpos(20, 0)
Curses.addstr("#{str}".ljust(40))
end
def display_search(search, selected, results, maxlen)
types = {:app => "A", :channel => "C"}
left = 44
setpos(1, left)
addstr(search.ljust(maxlen))
(0..9).each do |row|
setpos(row + 3, left)
if selected == row
Curses.attrset(Curses::A_REVERSE)
else
Curses.attrset(0)
end
if results[row]
addstr("#{types[results[row][0]]}: #{results[row][1]}".ljust(maxlen)[0..maxlen])
else
addstr("".ljust(maxlen))
end
end
Curses.attrset(0)
end
def display_button(action, flags = 0)
button = DISPLAYMAP[action]
Curses.setpos(button[0], button[1])
Curses.attrset(flags)
Curses.addstr(button[2])
Curses.setpos(button[0], button[1])
Curses.refresh
end
def run
init_screen
begin
crmode # unbuffer
noecho # do not echo as I type
nonl # no newlines
curs_set(0) # no cursor
stdscr.keypad = true # enable arrow
Curses.ESCDELAY = 0 # no delay on esc
init_layout
loop do
handle_key(getch)
end
ensure
close_screen
end
rescue Interrupt
end
end
Television.command(*ARGV)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment