├── 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 |
--------------------------------------------------------------------------------