├── Gemfile ├── README.markdown └── lgremote /Gemfile: -------------------------------------------------------------------------------- 1 | 2 | # lgremote requires the following gems 3 | 4 | gem "dnssd", ">=2.0" 5 | gem "patron", ">=0.4.15" 6 | gem "fsdb", ">=0.6.1" 7 | gem "highline", ">=1.6.2" 8 | 9 | # For bundler 1.1 onwards 10 | # bundle install --standalone 11 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | # Lgremote cmdline program 2 | 3 | A command line program to remotely control 2011 LG "Smart" TVs. 4 | 5 | Supported models: 6 | 7 | Any 2011 LG TVs labelled "Smart TV"* 8 | 9 | For example: 10 | LV5500,LW5500,LW6500,LW7700,LW9800 11 | LV550x,LW550x,LW650x,LW770x,LW980x 12 | LV550T,LW550T,LW650T,LW770T,LW980T 13 | 14 | This program is strictly only meant for LG "Smart TV" devices and not compatible with any other LG products (blue-ray players, phones, etc).* 15 | 16 | This script may no longer work with newer "LG SMART TV" models released after 2011. They now use the "ROAP" protocol. So if you see an error, try "lgCommander" instead: 17 | 18 | https://github.com/ubaransel/lgcommander 19 | 20 | ## Networking Requirements 21 | 22 | Be sure that: 23 | 24 | * Both computer + TV are on the same LAN segment. 25 | * uPNP is enabled on the local router. 26 | * You have assigned a FIXED ip address to your LG Smart TV. 27 | 28 | ## Platform Requirements 29 | 30 | Platform support: Mac OS X - good, Linux - ok, Windows - poor. 31 | 32 | This is a Ruby command line program. So it requires Ruby, and several rubygems installed in order to operate. The Ruby interpreter needs to have been installed with READLINE support for the small part of the program which handles interactive keyboard input. 33 | 34 | The Gem dependencies are listed in Gemfile. Some gem dependencies will fail to compile without their required C libraries. Apple Macs already have everything installed and this script was tested exclusively on Mac OS X. If you DO have an Apple Mac, skip this section of the README. If you have some other operating system, then the remaining hints here may form essential reading. However your mileage may vary considerably. 35 | 36 | Curl (`libcurl`) is required for the patron gem, which does all the HTTP portion of communications. On Linux you may need to install the optional libcurl-dev package to get the patron gem extensions to compile. For Windows, unfortunately Patron and Curl are pretty difficult to install. See [here](https://github.com/toland/patron/issues/2) for ideas. If all else fails, consider running this script in a VmWare virtual machine (linux guest, network bridge mode). 37 | 38 | Another simple approach to get Windows working is to replace the patron API with your own Windows-compatible functions and use a different underlying mechanism for sending the actual HTTP get and HTTP post requests. There are only a couple functions to implement. 39 | 40 | TV auto-discovery is provided by the `dnssd` gem (multicast DNS). It requires `dns_sd.h`, which can be provided by the Bonjour library (Apple's Bonjour for Windows), or Avahi (Linux). Mac OS X already comes with the necessary `dns_sd.h` API and runtimes (aka `mdnsResponder` daemon). 41 | 42 | Again, here Windows is problematic and does not meet the requirements easily. After installing Apple's Bonjour for Windows, you apparently need to go away and find the location of the `dns_sd` runtime library. There are some discussion elsewhere on the net about this. Then somehow, point it to the 'dns_sd' gem extensions so that they can find the Bonjour library + headers, and link against it. I have no idea how you are going to do that. 43 | 44 | A simpler solution might be to just disable and get rid of this portion of the program entirely. You must get in there and remove (comment out) the line that says `require dnssd`. Then proceed to remove all those functions which perform the TV auto-discovery command(s). That of course also means that you can't auto-discover the TV during pairing in the usual wayanymore. Therefore (as part of the same pair command) an alternative method of pairing has also been provided where you can just manually input the TV's IP address. 45 | 46 | ## Installation 47 | 48 | Bundler can try to install all of the necessary gem dependencies for you. 49 | 50 | $ gem install bundler 51 | $ cd lgremote 52 | $ bundle install 53 | 54 | If you have bundler 1.1 onwards, you can stage all the dependencies directly within the lgremote folder. 55 | 56 | $ bundle install --standalone 57 | 58 | Finally, add the directory containing the `lgremote` program to your `$PATH`. 59 | 60 | $ echo "export PATH=$PATH:$PWD" >> ~/.profile 61 | 62 | ## Usage 63 | 64 | Interactive pairing 65 | lgremote pair 66 | 67 | Display pairing key 68 | lgremote pair 192.168.1.2 69 | 70 | Enter pairing key 71 | lgremote pair 192.168.1.2 AAABBB 72 | 73 | Show all buttons 74 | lgremote press 75 | 76 | Show all buttons in group "Menus" 77 | lgremote press menus 78 | 79 | Press button 80 | lgremote press volume_up 81 | lgremote press volume_down 82 | 83 | Move mouse by 1 increment 84 | lgremote mouse up 85 | lgremote mouse down 86 | lgremote mouse left 87 | lgremote mouse right 88 | 89 | Move mouse by +- {x,y} pixels 90 | lgremote mouse -25 0 91 | 92 | Interactive text entry (tab updates) 93 | lgremote keyboard 94 | 95 | Non-interactive text entry 96 | lgremote keyboard text_string 97 | 98 | ## Copyright 99 | 100 | lgremote is provided Copyright © 2011 under MIT License. 101 | 102 | 103 | -------------------------------------------------------------------------------- /lgremote: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # 3 | # lgremote 4 | # A command line program to control for LG "Smart" TVs. 5 | # LV5500,LW5500,LW6500,LW7700,LW9800 6 | # LV550x,LW550x,LW650x,LW770x,LW980x 7 | 8 | # Mouse settings 9 | $mouse_move_start_incr = 15 # pixels 10 | $mouse_incr_multiplier = 1.27 # factor 11 | $mouse_incr_reset_thr = 1.5 # seconds 12 | 13 | # LgRemote config directory 14 | $lgremote_config = "#{ENV["HOME"]}/.lgremote" 15 | 16 | # = MIT License 17 | # 18 | # Copyright (c) 2011 Dreamcat4 19 | # 20 | # Permission is hereby granted, free of charge, to any person obtaining 21 | # a copy of this software and associated documentation files (the 22 | # "Software"), to deal in the Software without restriction, including 23 | # without limitation the rights to use, copy, modify, merge, publish, 24 | # distribute, sublicense, and/or sell copies of the Software, and to 25 | # permit persons to whom the Software is furnished to do so, subject to 26 | # the following conditions: 27 | # 28 | # The above copyright notice and this permission notice shall be 29 | # included in all copies or substantial portions of the Software. 30 | # 31 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 32 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 33 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 34 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 35 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 36 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 37 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 38 | # 39 | 40 | require "rubygems" 41 | 42 | # lgremote requires the following gems 43 | require "dnssd" 44 | require "patron" 45 | require "fsdb" 46 | require "highline/import" 47 | 48 | require "socket" 49 | require "zlib" 50 | require "timeout" 51 | 52 | class Hash 53 | # def + hash1, hash2 54 | # hash1.merge hash2 55 | # end 56 | def + hash 57 | merge hash 58 | end 59 | end 60 | 61 | module LgRemote 62 | module ActiveSupport 63 | # 64 | # ActiveSupport::OrderedHash 65 | # 66 | # Copyright (c) 2005 David Hansson, 67 | # Copyright (c) 2007 Mauricio Fernandez, Sam Stephenson 68 | # Copyright (c) 2008 Steve Purcell, Josh Peek 69 | # Copyright (c) 2009 Christoffer Sawicki 70 | # 71 | # Permission is hereby granted, free of charge, to any person obtaining 72 | # a copy of this software and associated documentation files (the 73 | # "Software"), to deal in the Software without restriction, including 74 | # without limitation the rights to use, copy, modify, merge, publish, 75 | # distribute, sublicense, and/or sell copies of the Software, and to 76 | # permit persons to whom the Software is furnished to do so, subject to 77 | # the following conditions: 78 | # 79 | # The above copyright notice and this permission notice shall be 80 | # included in all copies or substantial portions of the Software. 81 | # 82 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 83 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 84 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 85 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 86 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 87 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 88 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 89 | # 90 | class OrderedHash < Hash 91 | def initialize(*args, &block) 92 | super *args, &block 93 | @keys = [] 94 | end 95 | 96 | def self.[](*args) 97 | ordered_hash = new 98 | 99 | if (args.length == 1 && args.first.is_a?(Array)) 100 | args.first.each do |key_value_pair| 101 | next unless (key_value_pair.is_a?(Array)) 102 | ordered_hash[key_value_pair[0]] = key_value_pair[1] 103 | end 104 | 105 | return ordered_hash 106 | end 107 | 108 | if (args.first.is_a?(Hash)) 109 | args.each do |h| 110 | next unless (h.is_a?(Hash)) 111 | h.each do |k,v| 112 | ordered_hash[k] = v 113 | end 114 | end 115 | return ordered_hash 116 | end 117 | 118 | unless (args.size % 2 == 0) 119 | raise ArgumentError.new("odd number of arguments for Hash") 120 | end 121 | 122 | args.each_with_index do |val, ind| 123 | next if (ind % 2 != 0) 124 | ordered_hash[val] = args[ind + 1] 125 | end 126 | 127 | ordered_hash 128 | end 129 | 130 | def initialize_copy(other) 131 | super 132 | # make a deep copy of keys 133 | @keys = other.keys 134 | end 135 | 136 | def store(key, value) 137 | @keys << key if !has_key?(key) 138 | super 139 | end 140 | 141 | def []=(key, value) 142 | @keys << key if !has_key?(key) 143 | super 144 | end 145 | 146 | def delete(key) 147 | if has_key? key 148 | index = @keys.index(key) 149 | @keys.delete_at index 150 | end 151 | super 152 | end 153 | 154 | def delete_if 155 | super 156 | sync_keys! 157 | self 158 | end 159 | 160 | def reject! 161 | super 162 | sync_keys! 163 | self 164 | end 165 | 166 | def reject(&block) 167 | dup.reject!(&block) 168 | end 169 | 170 | def keys 171 | (@keys || []).dup 172 | end 173 | 174 | def values 175 | @keys.collect { |key| self[key] } 176 | end 177 | 178 | def to_hash 179 | self 180 | end 181 | 182 | def to_a 183 | @keys.map { |key| [ key, self[key] ] } 184 | end 185 | 186 | def each_key 187 | @keys.each { |key| yield key } 188 | end 189 | 190 | def each_value 191 | @keys.each { |key| yield self[key]} 192 | end 193 | 194 | def each 195 | @keys.each {|key| yield [key, self[key]]} 196 | end 197 | 198 | alias_method :each_pair, :each 199 | 200 | def clear 201 | super 202 | @keys.clear 203 | self 204 | end 205 | 206 | def shift 207 | k = @keys.first 208 | v = delete(k) 209 | [k, v] 210 | end 211 | 212 | def merge!(other_hash) 213 | other_hash.each {|k,v| self[k] = v } 214 | self 215 | end 216 | 217 | def merge(other_hash) 218 | dup.merge!(other_hash) 219 | end 220 | 221 | # When replacing with another hash, the initial order of our keys must come from the other hash -ordered or not. 222 | def replace(other) 223 | super 224 | @keys = other.keys 225 | self 226 | end 227 | 228 | def inspect 229 | "#" 230 | end 231 | 232 | private 233 | 234 | def sync_keys! 235 | @keys.delete_if {|k| !has_key?(k)} 236 | end 237 | end 238 | end 239 | end 240 | 241 | module LgRemote 242 | if RUBY_VERSION >= '1.9' 243 | # Inheritance 244 | # Ruby 1.9 < Hash 245 | # Ruby 1.8 < ActiveSupport::OrderedHash 246 | class OrderedHash < ::Hash 247 | end 248 | else 249 | # Inheritance 250 | # Ruby 1.9 < Hash 251 | # Ruby 1.8 < ActiveSupport::OrderedHash 252 | class OrderedHash < LgRemote::ActiveSupport::OrderedHash 253 | end 254 | end 255 | end 256 | 257 | $menus = LgRemote::OrderedHash[ 258 | :status_bar, 35, 259 | :quick_menu, 69, 260 | :home_menu, 67, 261 | :premium_menu, 89, 262 | :installation_menu, 207, 263 | :factory_advanced_menu1, 251, 264 | :factory_advanced_menu2, 255, 265 | ] 266 | 267 | $power_controls = LgRemote::OrderedHash[ 268 | :power_off, 8, 269 | :sleep_timer, 14, 270 | ] 271 | 272 | $navigation = LgRemote::OrderedHash[ 273 | :left, 7, 274 | :right, 6, 275 | :up, 64, 276 | :down, 65, 277 | :select, 68, 278 | :back, 40, 279 | :exit, 91, 280 | :red, 114, 281 | :green, 113, 282 | :yellow, 99, 283 | :blue, 97, 284 | ] 285 | 286 | $keypad = LgRemote::OrderedHash[ 287 | :"0", 16, 288 | :"1", 17, 289 | :"2", 18, 290 | :"3", 19, 291 | :"4", 20, 292 | :"5", 21, 293 | :"6", 22, 294 | :"7", 23, 295 | :"8", 24, 296 | :"9", 25, 297 | :underscore, 76, 298 | ] 299 | 300 | $playback_controls = LgRemote::OrderedHash[ 301 | :play, 176, 302 | :pause, 186, 303 | :fast_forward, 142, 304 | :rewind, 143, 305 | :stop, 177, 306 | :record, 189, 307 | ] 308 | 309 | $input_controls = LgRemote::OrderedHash[ 310 | :tv_radio, 15, 311 | :simplink, 126, 312 | :input, 11, 313 | :component_rgb_hdmi, 152, 314 | :component, 191, 315 | :rgb, 213, 316 | :hdmi, 198, 317 | :hdmi1, 206, 318 | :hdmi2, 204, 319 | :hdmi3, 233, 320 | :hdmi4, 218, 321 | :av1, 90, 322 | :av2, 208, 323 | :av3, 209, 324 | :usb, 124, 325 | :slideshow_usb1, 238, 326 | :slideshow_usb2, 168, 327 | ] 328 | 329 | $tv_controls = LgRemote::OrderedHash[ 330 | :channel_up, 0, 331 | :channel_down, 1, 332 | :channel_back, 26, 333 | :favorites, 30, 334 | :teletext, 32, 335 | :t_opt, 33, 336 | :channel_list, 83, 337 | :greyed_out_add_button?, 85, 338 | :guide, 169, 339 | :info, 170, 340 | :live_tv, 158, 341 | ] 342 | 343 | $picture_controls = LgRemote::OrderedHash[ 344 | :av_mode, 48, 345 | :picture_mode, 77, 346 | :ratio, 121, 347 | :ratio_4_3, 118, 348 | :ratio_16_9, 119, 349 | :energy_saving, 149, 350 | :cinema_zoom, 175, 351 | :"3d", 220, 352 | :factory_picture_check, 252, 353 | ] 354 | 355 | $audio_controls = LgRemote::OrderedHash[ 356 | :volume_up, 2, 357 | :volume_down, 3, 358 | :mute, 9, 359 | :audio_language, 10, 360 | :sound_mode, 82, 361 | :factory_sound_check, 253, 362 | :subtitle_language, 57, 363 | :audio_description, 145, 364 | ] 365 | 366 | $keymap = \ 367 | $menus + $power_controls + \ 368 | $navigation + $keypad + $playback_controls + \ 369 | $input_controls + $tv_controls + \ 370 | $picture_controls + $audio_controls 371 | 372 | $keymap_strings = LgRemote::OrderedHash[ 373 | "Menus", $menus, 374 | "Power controls", $power_controls, 375 | "Navigation", $navigation, 376 | "Keypad", $keypad, 377 | "Playback controls", $playback_controls, 378 | "Input controls", $input_controls, 379 | "TV controls", $tv_controls, 380 | "Picture controls", $picture_controls, 381 | "Audio controls", $audio_controls 382 | ] 383 | 384 | labels_array = $keymap_strings.map do |s,h| 385 | [s.downcase.tr(" ","_").to_sym,h] 386 | end 387 | $keymap_labels = Hash[labels_array] 388 | 389 | def create_session lgtv 390 | $sess = Patron::Session.new 391 | $sess.timeout = 5.0 392 | $sess.base_url = "http://#{lgtv[:address]}:8080" 393 | $headers = {"Content-Type" => "application/atom+xml" } 394 | end 395 | 396 | def load_config_open_session 397 | unless File.exist?($lgremote_config) 398 | print "Config files missing. Please pair with \"lgremote pair\"\n\n" 399 | help 400 | exit 1 401 | end 402 | 403 | $db = FSDB::Database.new($lgremote_config) 404 | $lgtv = $db[$db["default"]] 405 | # puts $lgtv.inspect 406 | 407 | create_session $lgtv 408 | end 409 | 410 | def reconnect failed_resp 411 | # puts "error" 412 | # puts failed_resp.body 413 | # 401Unauthorized 414 | error_detail = failed_resp.body.gsub(/.*/,"").gsub(/<\/HDCPErrorDetai>.*/,"") 415 | if error_detail.downcase =~ /unauthorized/ 416 | resp = $sess.post("/hdcp/api/auth","AuthReq#{$lgtv[:pairing_key]}",$headers) 417 | if resp.status == 200 418 | # Obtain session number 419 | session = resp.body.gsub(/.*/,"").gsub(/<\/session>.*/,"") 420 | if session =~ /[0-9]{9}/ 421 | # puts "Connection re-established." 422 | # store information for next invocation 423 | $lgtv[:session] = session 424 | $db["#{$lgtv[:address]}"] = $lgtv # save 425 | # puts "Session saved." 426 | end 427 | return true 428 | else 429 | raise "Session timed out. But we failed to re-establish a connection." 430 | end 431 | else 432 | raise failed_resp.body 433 | end 434 | end 435 | 436 | def event name, value=nil 437 | if value 438 | resp = $sess.post("/hdcp/api/event","#{$lgtv[:session]}#{name}#{value}",$headers) 439 | else 440 | resp = $sess.post("/hdcp/api/event","#{$lgtv[:session]}#{name}",$headers) 441 | end 442 | 443 | if resp.status == 200 444 | # puts resp.body 445 | # 200OK114859659 446 | else 447 | reconnect resp 448 | event name, value 449 | end 450 | end 451 | 452 | def change_channel assigned_no, real_no, uhf_no 453 | # The 3 parameters must match and agree with whats currently stored in the memory of the TV 454 | # otherwise we get a blank screen. Problem is the API doesnt let us query such information. 455 | # Note: If you add all your channels to one of the favorites group, we could download them. 456 | # But information would go stale whenever the user chooses to update their channel mappings. 457 | # "483166968HandleChannelChange77134" 458 | resp = $sess.post("/hdcp/api/dtv_wifirc","#{$lgtv[:session]}HandleChannelChange#{assigned_no}#{real_no}1#{uhf_no}",$headers) 459 | if resp.status == 200 460 | puts resp.body 461 | # 200OK114859659 462 | else 463 | reconnect resp 464 | change_channel assigned_no, real_no 465 | end 466 | end 467 | 468 | # def get_favorites 469 | # # Returns information about channels in the favorites groups A,B,C,D 470 | # # GET /hdcp/api/data?target=fav_list&session=1664204142 471 | # resp = $sess.get("/hdcp/api/data?target=fav_list&session=#{$lgtv[:session]}",$headers) 472 | # if resp.status == 200 473 | # resp.body 474 | # else 475 | # reconnect resp 476 | # get_favorites 477 | # end 478 | # end 479 | 480 | # def get_model_name 481 | # # GET "/hdcp/api/data?target=model_info&session=" 482 | # resp = $sess.get("/hdcp/api/data?target=model_info&session=#{$lgtv[:session]}",$headers) 483 | # if resp.status == 200 484 | # resp.body.gsub(/.*/,"").gsub(/<\/modelName>.*/,"") 485 | # else 486 | # reconnect resp 487 | # get_model_info 488 | # end 489 | # end 490 | 491 | # def get_cur_channel 492 | # # Gives invalid data when in menus, or external input (eg HDMI) 493 | # # GET "/hdcp/api/data?target=cur_channel&session=" 494 | # resp = $sess.get("/hdcp/api/data?target=cur_channel&session=#{$lgtv[:session]}",$headers) 495 | # if resp.status == 200 496 | # resp.body 497 | # else 498 | # reconnect resp 499 | # get_cur_channel 500 | # end 501 | # end 502 | 503 | def cursor_show 504 | event "CursorVisible", true 505 | end 506 | 507 | def reverse_2bytes hexstr 508 | hexstr[2..3]+hexstr[0..1] 509 | end 510 | 511 | def reverse_4bytes hexstr 512 | hexstr[6..7]+hexstr[4..5]+hexstr[2..3]+hexstr[0..1] 513 | end 514 | 515 | def prepare_2bytes uint16_value 516 | # We wrap integers in an array [] so we can perform binary conversions. 517 | # See Ruby's Array.pack() method for an explanation. A simple example: 518 | # data << [0].pack("N*").unpack("H*") 519 | reverse_2bytes [uint16_value].pack("n*").unpack("H*").first 520 | end 521 | 522 | def prepare_4bytes uint32_value 523 | # We wrap integers in an array [] so we can perform binary conversions. 524 | # See Ruby's Array.pack() method for an explanation. A simple example: 525 | # data << [0].pack("N*").unpack("H*") 526 | reverse_4bytes [uint32_value].pack("N*").unpack("H*").first 527 | end 528 | 529 | def craft_packet cmd0, cmd1, byte0, byte1=nil, byte2=nil, str=nil 530 | # UDP packets captured with wireshark. 531 | # Just type "udp.port == 7070" into the filter box. 532 | 533 | # <------------ UDP Payload 18, 22, or 26 bytes --------------> 534 | # 1) Original Message 535 | # uint32 uint32 uint16 uint32 uint32 uint32 uint32 536 | # <---------> <---------> <---> <---------> <---------> <---------> <---------> 537 | # 00:00:00:00 54:13:43:65 02:00 08:00:00:00 00:00:00:00 04:00:00:00 04:00:00:00 538 | # <---------> <---------> <---> <---------> <---------> <---------> <---------> 539 | # Zero-pad session cmd1 cmd2 data1 data2* data3* 540 | # 541 | # * The data2 and data3 are optional extra arguments 542 | 543 | # Each *individual* fields are little endian (LSB first --> MSB last) 544 | # Its not as simple as the expected network native big endian. 545 | # We must reverse each individual field from the Network order. 546 | # So for example cmd1 "02:00" is actually (uint16)0x02 547 | # and cmd2 "08:00:00:00" == (uint32)0x08 548 | 549 | # 2) Final Message with crc32 checksum filled in. 550 | # Where "crc32" field = crc32() of zero-padded Message 1) above 551 | # 03:14:6b:6d 54:13:43:65 02:00 08:00:00:00 00:00:00:00 04:00:00:00 552 | # <---------> <---------> <---> <---------> <---------> <---------> 553 | # crc32 session cmd1 cmd2 data1 data2* 554 | # 555 | # Final UDP packet 556 | # "03:14:6b:6d:54:13:43:65:02:00:08:00:00:00 00:00:00:00 04:00:00:00" 557 | 558 | data = [] 559 | 560 | data << "00000000" # Zero-pad 561 | data << prepare_4bytes( $lgtv[:session].to_i ) # session 562 | 563 | data << prepare_2bytes( cmd0 ) # cmd1 564 | data << prepare_4bytes( cmd1 ) # cmd2 565 | 566 | data << prepare_4bytes( byte0 ) # data1 567 | 568 | if byte1 || byte2 569 | data << prepare_4bytes( byte1 ) if byte1 # data2 570 | data << prepare_4bytes( byte2 ) if byte2 # data3 571 | 572 | elsif str 573 | # For text input mode, there is no data2, data3. 574 | # Instead we (re-)update the whole textbox. With a variable-length ASCII string 575 | # f1:db:b2:5d 91:76:f6:15 09:00 0d:00:00:00 01:00:00:00 74:6f:74:6f:74:6f:74 00:00 576 | # t 0 t 0 t 0 t \0 \0 577 | data << str.unpack("H*").first 578 | data << ["0000"].pack("H*") # trailing NULLs [0x00, 0x00] 579 | end 580 | 581 | # Before checksum 582 | # puts "data = #{data.to_s}" 583 | 584 | crc32 = Zlib::crc32(["#{data}"].pack('H*')) 585 | data[0]=prepare_4bytes( crc32 ) # crc32 586 | 587 | # After checksum 588 | # puts "data = #{data.to_s.inspect}" 589 | 590 | bytes = ["#{data}"].pack('H*') 591 | return bytes 592 | end 593 | 594 | def send_packet bytes 595 | # puts bytes 596 | sock = UDPSocket.new 597 | sock.send(bytes, 0, $lgtv[:address], 7070) 598 | sock.close 599 | end 600 | 601 | def move_mouse px, py 602 | cursor_show 603 | cmd = [2,8] # move mouse 604 | bytes = craft_packet( cmd[0], cmd[1], px, py) 605 | i = 0 606 | n = 4 607 | while i < n 608 | send_packet bytes 609 | i += 1 610 | sleep 0.1 611 | end 612 | end 613 | 614 | def click_mouse 615 | cursor_show 616 | cmd = [3,4] 617 | bytes = craft_packet(cmd[0],cmd[1], 0x02) 618 | send_packet bytes 619 | end 620 | 621 | def enter_text str 622 | # cmd = [ 9, 6 + str.size ] 623 | # cmd = [ 9, 8 + str.size ] 624 | cmd = [ 9, str.size ] 625 | bytes = craft_packet( cmd[0],cmd[1], 0x01, nil, nil, str ) 626 | send_packet bytes 627 | end 628 | 629 | class String 630 | # Remove the leading spaces of the first line, and same to all lines of a multiline string. 631 | # This effectively shifts all the lines across to the left, until the first line hits the 632 | # left margin. 633 | # @example 634 | # def usage; <<-EOS.undent 635 | # # leading indent 636 | # # subsequent indent 637 | # # subsequent indent + ' ' 638 | # EOS 639 | # end 640 | def undent 641 | gsub /^.{#{slice(/^ +/).length}}/, '' 642 | end 643 | end 644 | 645 | $cmd = "$ #{File.basename $0}" 646 | 647 | def usage 648 | <<-EOS.undent 649 | Usage: 650 | #{$cmd} 651 | 652 | Interactive pairing 653 | #{$cmd} pair 654 | 655 | Display pairing key 656 | #{$cmd} pair 192.168.1.2 657 | 658 | Enter pairing key 659 | #{$cmd} pair 192.168.1.2 AAABBB 660 | 661 | Show all buttons 662 | #{$cmd} press 663 | 664 | Show all buttons in group "Menus" 665 | #{$cmd} press menus 666 | 667 | Press button 668 | #{$cmd} press volume_up 669 | #{$cmd} press volume_down 670 | 671 | Move mouse by 1 increment 672 | #{$cmd} mouse up 673 | #{$cmd} mouse down 674 | #{$cmd} mouse left 675 | #{$cmd} mouse right 676 | 677 | Move mouse by +- {x,y} pixels 678 | #{$cmd} mouse -25 0 679 | 680 | Interactive text entry (tab updates) 681 | #{$cmd} keyboard 682 | 683 | Non-interactive text entry 684 | #{$cmd} keyboard text_string 685 | 686 | EOS 687 | end 688 | 689 | def help 690 | puts usage 691 | end 692 | 693 | def bad_arg arg 694 | print "Unrecognised argument #{arg.inspect}.\n\n" 695 | help 696 | end 697 | 698 | def missing_arg_after arg 699 | print "Missing argument after #{arg}.\n\n" 700 | puts usage 701 | end 702 | 703 | class DNSSD::Reply::Browse < DNSSD::Reply 704 | attr_reader :addresses 705 | attr_reader :address 706 | 707 | def resolve! 708 | reply = self 709 | @addresses = [] 710 | resolver = DNSSD.resolve! reply.name, reply.type, 'local' do |reply| 711 | service = DNSSD::Service.new 712 | 713 | service.getaddrinfo reply.target do |addrinfo| 714 | @addresses << addrinfo.address 715 | break unless addrinfo.flags.more_coming? 716 | end 717 | break 718 | end 719 | @address = @addresses.first 720 | end 721 | 722 | def inspect 723 | return "#{name} #{address} (#{name}.#{type}.#{domain.chop})" 724 | end 725 | end 726 | 727 | class DNSSD::Service 728 | def self.find service, timeout=2.0 729 | browser = DNSSD::Service.new 730 | replies = [] 731 | begin 732 | Timeout::timeout(timeout) do 733 | browser.browse service do |reply| 734 | reply.resolve! 735 | replies << reply 736 | end 737 | end 738 | rescue Timeout::Error 739 | rescue 740 | end 741 | return replies 742 | end 743 | end 744 | 745 | def pair_show_pairing_key lgtv 746 | create_session lgtv 747 | resp = $sess.get("/hdcp/api/data?target=version_info",$headers) 748 | if resp.status == 200 749 | resp = $sess.post("/hdcp/api/auth","AuthKeyReq",$headers) 750 | if resp.status == 200 751 | # puts resp.body 752 | # If xml contains nodes HDCPError=200 && HDCPErrorDetail=OK 753 | # This means the Pairing key is currently being displayed on the TV 754 | db = FSDB::Database.new($lgremote_config) 755 | db["#{lgtv[:address]}"] = lgtv 756 | db["default"] = lgtv[:address] 757 | say "Success" 758 | say "A 6-digit pairing key should be displayed on your TV" 759 | say "Session saved." 760 | end 761 | else 762 | raise resp.body 763 | end 764 | end 765 | 766 | def pair_with_lgtv lgtv 767 | create_session lgtv 768 | resp = $sess.post("/hdcp/api/auth","AuthReq#{lgtv[:pairing_key]}",$headers) 769 | if resp.status == 200 770 | # Obtain session number 771 | session = resp.body.gsub(/.*/,"").gsub(/<\/session>.*/,"") 772 | if session =~ /[0-9]{9}/ 773 | lgtv[:session] = session 774 | lgtv[:mouse_last_moved] = Time.new 775 | say "Pairing successful" 776 | 777 | # store information for next invocation 778 | db = FSDB::Database.new($lgremote_config) 779 | db["#{lgtv[:address]}"] = lgtv 780 | db["default"] = lgtv[:address] 781 | say "Session saved." 782 | end 783 | else 784 | puts "Pairing failed." 785 | puts "AuthReq#{pairing_key}" 786 | puts resp.body 787 | end 788 | end 789 | 790 | def pair_interactive 791 | timeout=1.0 792 | replies = DNSSD::Service.find("_lg_dtv_wifirc._tcp",timeout) 793 | 794 | # for testing multiple TVs selection list 795 | # replies << replies.first.dup 796 | # replies << replies.first.dup 797 | 798 | case replies.size 799 | when 0 800 | say "No LG Smart TVs were found on your network." 801 | say "Please check that:" 802 | say "TV model is actually labelled as an LG \"SMART\" TV *" 803 | say "TV is switched on and NOT stuck in the menu." 804 | say "Both computer + TV are on the same LAN segment." 805 | say "uPNP is enabled on the local router." 806 | say " * Not all of LG's DLNA capable TVs are Smart TVs." 807 | exit 1 808 | when 1 809 | puts "One TV found" 810 | puts replies.first.inspect 811 | 812 | else 813 | puts replies.first.inspect 814 | match = agree("Is this your TV? ", true) 815 | 816 | unless match 817 | puts "#{replies.size} TVs found." 818 | 819 | choice = choose do |menu| 820 | menu.prompt = "Which TV do you wish to pair?" 821 | 822 | replies.each do |reply| 823 | menu.choice reply.inspect 824 | end 825 | end 826 | 827 | say "You chose:" 828 | say choice.inspect 829 | exit unless agree("Continue?", true) 830 | 831 | replies.delete(choice) 832 | replies.insert(0,choice) 833 | end 834 | end 835 | 836 | r = replies.first 837 | replies.drop(1) 838 | lgtv = { :name => r.name, :address => r.address } 839 | 840 | pair_show_pairing_key lgtv 841 | 842 | # Gather user input 843 | # Obtain the pairing key from the user 844 | pairing_key = nil 845 | pairing_key_timeout = 60.0 846 | begin 847 | Timeout::timeout(pairing_key_timeout) do 848 | lgtv[:pairing_key] = ask("Please enter the 6-letter pairing key, as displayed on the TV:") { |q| q.validate = /[a-zA-Z]{6}/ }.upcase 849 | end 850 | rescue Timeout::Error 851 | "Timeout." 852 | exit 853 | rescue 854 | exit 855 | end 856 | pair_with_lgtv lgtv 857 | end 858 | 859 | def pair args 860 | # pair 861 | # pair 192.168.1.2 862 | # pair 192.168.1.2 AAABBB 863 | case args[0] 864 | when nil 865 | # interactive bonjour 866 | pair_interactive 867 | when /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})?$/ 868 | # ip address given 869 | case args[1] 870 | when nil 871 | pair_show_pairing_key :name => "LG Smart TV", :address => args[0] 872 | when /[a-zA-Z]{6}/ 873 | # ip address + pairing key given 874 | pair_with_lgtv :name => "LG Smart TV", :address => args[0], :pairing_key => args[1].upcase 875 | else 876 | bad_arg args[1] 877 | end 878 | else 879 | bad_arg args[0] 880 | end 881 | end 882 | 883 | def press_udp key 884 | # eg 885 | # volume UP = 0x02 886 | # f3:b1:cb:9e 33:a8:89:5b 01:00 04:00:00:00 02:00:00:00 887 | # volume DOWN = 0x03 888 | # 96:d6:77:26 33:a8:89:5b 01:00 04:00:00:00 03:00:00:00 889 | cmd = [1,4] 890 | bytes = craft_packet( cmd[0],cmd[1], key.to_i ) 891 | send_packet bytes 892 | end 893 | 894 | def press_tcp key 895 | # key = lookup(key) 896 | resp = $sess.post("/hdcp/api/dtv_wifirc","#{$lgtv[:session]}HandleKeyInput#{key}",$headers) 897 | if resp.status == 200 898 | resp.body 899 | else 900 | reconnect resp 901 | press key 902 | end 903 | end 904 | 905 | def show_keymap label=nil 906 | if label 907 | $keymap_strings.each do |l, h| 908 | if label == l.downcase.tr(" ","_").to_sym 909 | print "#{l}:\n " 910 | puts h.keys.join("\n ") 911 | end 912 | end 913 | else 914 | $keymap_strings.each do |label, keymap| 915 | print "#{label}:\n " 916 | puts keymap.keys.join("\n ") 917 | print "\n" 918 | end 919 | end 920 | end 921 | 922 | def press args 923 | # press quick_menu 924 | # press mute 925 | case args[0] 926 | when nil, "help" 927 | # print list of available commands 928 | show_keymap 929 | else 930 | label = args.join("_").downcase.to_sym 931 | if $keymap_labels.keys.include?(label) 932 | show_keymap label 933 | else 934 | if $keymap.keys.include?(label) 935 | press_tcp $keymap[label] 936 | else 937 | bad_arg args[0] 938 | show_keymap 939 | end 940 | end 941 | end 942 | end 943 | 944 | def keyboard args 945 | # keyboard 946 | # keyboard "text input" 947 | reconnect 948 | if args[0] 949 | puts args.inspect 950 | enter_text args.join(" ") 951 | else 952 | require 'readline' 953 | Readline.basic_word_break_characters="" 954 | Readline.completion_proc = proc{ |s| enter_text(s); nil } 955 | buf = Readline.readline("Enter text: ", true) 956 | enter_text buf 957 | end 958 | end 959 | 960 | $incr = 0 961 | def calc_incr 962 | if (Time.new - $lgtv[:mouse_last_moved]) > $mouse_incr_reset_thr 963 | $incr = $mouse_move_start_incr 964 | else 965 | # This should be limited 966 | $incr = ($lgtv[:mouse_move_incr] * $mouse_incr_multiplier).to_i 967 | end 968 | $lgtv[:mouse_move_incr] = $incr 969 | $lgtv[:mouse_last_moved] = Time.new 970 | $db["#{$lgtv[:address]}"] = $lgtv # save 971 | end 972 | 973 | def mouse args 974 | # mouse up 975 | # mouse down 976 | # mouse left 977 | # mouse right 978 | # mouse -25 0 979 | case args[0] 980 | when nil 981 | missing_arg_after "mouse" 982 | when /^[+-]?[0-9]$/ 983 | missing_arg_after args[0] unless args[1] 984 | case args[1] 985 | when /^[+-]?[0-9]$/ 986 | move_mouse args[0], args[1] 987 | else 988 | bad_arg args[1] 989 | end 990 | else 991 | calc_incr 992 | case args[0].to_sym 993 | when :show 994 | move_mouse(0,0) 995 | when :left 996 | move_mouse(-$incr,0) 997 | when :right 998 | move_mouse(+$incr,0) 999 | when :up 1000 | move_mouse(0,-$incr) 1001 | when :down 1002 | move_mouse(0,+$incr) 1003 | when :click 1004 | click_mouse 1005 | else 1006 | bad_arg args[0] 1007 | end 1008 | end 1009 | end 1010 | 1011 | class NilClass 1012 | def to_sym 1013 | :nil 1014 | end 1015 | end 1016 | 1017 | def main_loop 1018 | valid_cmds = [:pair, :press, :mouse, :keyboard] 1019 | $args = ARGV.dup 1020 | if $args[0] 1021 | first_arg = $args[0].downcase.to_sym 1022 | if valid_cmds.include?(first_arg) 1023 | load_config_open_session unless first_arg == :pair 1024 | send $args[0].downcase.to_sym, $args.dup.drop(1) 1025 | else 1026 | bad_arg $args[0] 1027 | end 1028 | else 1029 | puts usage 1030 | end 1031 | end 1032 | 1033 | # Execute main loop 1034 | main_loop 1035 | --------------------------------------------------------------------------------