Last active
July 15, 2022 09:51
-
-
Save bazzargh/fcb19fbcc814294c0fb59faa568c04f0 to your computer and use it in GitHub Desktop.
discover tv control url for panasonic viera
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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