2020
2121import json
2222import os
23+ import select
2324import ssl
2425import sys
2526import threading
27+ import time
2628from typing import TYPE_CHECKING
2729
2830import websocket
@@ -45,7 +47,7 @@ class ShellSession:
4547 Programmatic::
4648
4749 session = create_shell_session(apisession, site_id, device_id)
48- session.send_text( "show version\\ r \\ n" )
50+ session.send_commands([ "show version"] )
4951 while session.connected:
5052 data = session.recv()
5153 if data:
@@ -55,7 +57,7 @@ class ShellSession:
5557 Context manager::
5658
5759 with create_shell_session(apisession, site_id, device_id) as session:
58- session.send_text( "show interfaces terse\\ r \\ n" )
60+ session.send_commands([ "show interfaces terse"] )
5961 import time; time.sleep(5)
6062 while True:
6163 data = session.recv()
@@ -92,6 +94,8 @@ def __init__(
9294 self ._rows = rows
9395 self ._cols = cols
9496 self ._ws : websocket .WebSocket | None = None
97+ self ._recv_buffer : list [bytes ] = []
98+ self ._shell_ready = False
9599
96100 # ------------------------------------------------------------------
97101 # Auth / SSL helpers (mirrors _MistWebsocket but avoids coupling)
@@ -165,6 +169,8 @@ def disconnect(self) -> None:
165169 """Close the WebSocket connection."""
166170 ws = self ._ws
167171 self ._ws = None
172+ self ._shell_ready = False
173+ self ._recv_buffer .clear ()
168174 if ws :
169175 try :
170176 ws .close ()
@@ -184,19 +190,31 @@ def send(self, data: bytes) -> None:
184190 """Send raw bytes (keystrokes) to the device shell."""
185191 ws = self ._ws
186192 if ws and ws .connected :
187- ws .send_binary (data )
193+ self ._wait_for_shell_ready ()
194+ if ws .connected :
195+ try :
196+ ws .send_binary (data )
197+ except websocket .WebSocketConnectionClosedException :
198+ pass
188199
189200 def send_text (self , text : str ) -> None :
190201 """Send a text string as binary data to the device shell."""
191202 self .send (f"\x00 { text } " .encode ("utf-8" ))
192203
204+ def send_commands (self , commands : list [str ]) -> None :
205+ """Send commands, adding a newline after each command."""
206+ text = "" .join (command .rstrip ("\n " ) + "\n " for command in commands )
207+ self .send_text (text )
208+
193209 def recv (self , timeout : float = 0.1 ) -> bytes | None :
194210 """
195211 Receive raw bytes from the device shell.
196212
197213 Returns None if no data is available within the timeout, or if
198214 the connection is closed.
199215 """
216+ if self ._recv_buffer :
217+ return self ._recv_buffer .pop (0 )
200218 ws = self ._ws
201219 if not ws or not ws .connected :
202220 return None
@@ -205,7 +223,9 @@ def recv(self, timeout: float = 0.1) -> bytes | None:
205223 ws .settimeout (timeout )
206224 data = ws .recv ()
207225 if isinstance (data , str ):
208- return data .encode ("utf-8" )
226+ data = data .encode ("utf-8" )
227+ if data :
228+ self ._shell_ready = True
209229 return data
210230 except websocket .WebSocketTimeoutException :
211231 return None
@@ -228,6 +248,51 @@ def recv(self, timeout: float = 0.1) -> bytes | None:
228248 exc ,
229249 )
230250
251+ def _wait_for_shell_ready (self , timeout : float = 10.0 ) -> None :
252+ """Wait for first shell output before sending keystrokes."""
253+ if self ._shell_ready :
254+ return
255+ ws = self ._ws
256+ if not ws or not ws .connected :
257+ return
258+
259+ old_timeout = ws .gettimeout ()
260+ deadline = time .monotonic () + timeout
261+ try :
262+ while (
263+ time .monotonic () < deadline and ws .connected and not self ._shell_ready
264+ ):
265+ ws .settimeout (min (0.25 , max (0.01 , deadline - time .monotonic ())))
266+ try :
267+ data = ws .recv ()
268+ except websocket .WebSocketTimeoutException :
269+ continue
270+ except (
271+ websocket .WebSocketConnectionClosedException ,
272+ ConnectionError ,
273+ ):
274+ return
275+ if isinstance (data , str ):
276+ data = data .encode ("utf-8" )
277+ if data :
278+ self ._recv_buffer .append (data )
279+ self ._shell_ready = True
280+ return
281+ self ._shell_ready = True
282+ finally :
283+ try :
284+ ws .settimeout (old_timeout )
285+ except (
286+ websocket .WebSocketConnectionClosedException ,
287+ ConnectionError ,
288+ OSError ,
289+ ) as exc :
290+ LOGGER .debug (
291+ "ShellSession._wait_for_shell_ready: failed to restore "
292+ "websocket timeout (socket may be closed): %s" ,
293+ exc ,
294+ )
295+
231296 def resize (self , rows : int , cols : int ) -> None :
232297 """Send a terminal resize message to the device."""
233298 self ._rows = rows
@@ -299,6 +364,80 @@ def create_shell_session(
299364 return session
300365
301366
367+ def _posix_input_loop (session : ShellSession ) -> None :
368+ """Forward raw keystrokes from a POSIX TTY until the session closes.
369+
370+ The terminal is put in raw mode, so control characters (including
371+ Ctrl+C) are forwarded to the device instead of being handled locally.
372+ """
373+ import termios
374+ import tty
375+
376+ stdin_fd = sys .stdin .fileno ()
377+ old_stdin_settings = termios .tcgetattr (stdin_fd )
378+ try :
379+ tty .setraw (stdin_fd )
380+ while session .connected :
381+ readable , _ , _ = select .select ([sys .stdin ], [], [], 0.1 )
382+ if not readable :
383+ continue
384+ data = os .read (stdin_fd , 1024 )
385+ if not data :
386+ break
387+ session .send (b"\x00 " + data )
388+ finally :
389+ termios .tcsetattr (stdin_fd , termios .TCSADRAIN , old_stdin_settings )
390+
391+
392+ # Second half of the two-part console key codes returned by msvcrt.getwch()
393+ # (after a "\x00"/"\xe0" prefix), mapped to the ANSI sequences the device
394+ # pty expects.
395+ _WINDOWS_KEY_ESCAPES = {
396+ "H" : "\x1b [A" , # up
397+ "P" : "\x1b [B" , # down
398+ "M" : "\x1b [C" , # right
399+ "K" : "\x1b [D" , # left
400+ "G" : "\x1b [H" , # home
401+ "O" : "\x1b [F" , # end
402+ "S" : "\x1b [3~" , # delete
403+ "I" : "\x1b [5~" , # page up
404+ "Q" : "\x1b [6~" , # page down
405+ }
406+
407+
408+ def _windows_input_loop (session : ShellSession ) -> None :
409+ """Forward keystrokes from the Windows console until the session closes.
410+
411+ Unlike the POSIX raw-mode loop, Ctrl+C raises KeyboardInterrupt here
412+ (the console is not in raw mode), so it ends the session locally.
413+ """
414+ import ctypes
415+ import msvcrt
416+
417+ # Legacy consoles need virtual terminal processing enabled to render
418+ # the ANSI sequences the device sends; Windows Terminal already has it.
419+ try :
420+ kernel32 = ctypes .windll .kernel32 # type: ignore[attr-defined]
421+ handle = kernel32 .GetStdHandle (- 11 ) # STD_OUTPUT_HANDLE
422+ mode = ctypes .c_uint32 ()
423+ if kernel32 .GetConsoleMode (handle , ctypes .byref (mode )):
424+ kernel32 .SetConsoleMode (handle , mode .value | 0x0004 )
425+ except (OSError , AttributeError ):
426+ pass
427+
428+ while session .connected :
429+ if not msvcrt .kbhit (): # type: ignore[attr-defined]
430+ time .sleep (0.05 )
431+ continue
432+ ch = msvcrt .getwch () # type: ignore[attr-defined]
433+ if ch in ("\x00 " , "\xe0 " ):
434+ seq = _WINDOWS_KEY_ESCAPES .get (msvcrt .getwch ()) # type: ignore[attr-defined]
435+ if seq is None :
436+ continue
437+ ch = seq
438+ session .send (b"\x00 " + ch .encode ("utf-8" ))
439+
440+
302441def interactive_shell (
303442 apisession : "APISession" ,
304443 site_id : str ,
@@ -308,8 +447,10 @@ def interactive_shell(
308447 Launch an interactive SSH shell session to a device.
309448
310449 Takes over the terminal: captures keystrokes, sends them to the device,
311- and displays output. Blocks until the connection closes or the user
312- presses Ctrl+C.
450+ and displays output. Blocks until the connection closes (e.g. after
451+ typing ``exit`` on the device). On POSIX systems the terminal runs in
452+ raw mode, so Ctrl+C is forwarded to the device rather than ending the
453+ session; on Windows, Ctrl+C ends the session locally.
313454
314455 PARAMS
315456 -----------
@@ -319,8 +460,19 @@ def interactive_shell(
319460 UUID of the site where the device is located.
320461 device_id : str
321462 UUID of the device to connect to.
463+
464+ RAISES
465+ -----------
466+ RuntimeError
467+ If stdin is not an interactive terminal (TTY). Use ShellSession
468+ for programmatic access.
322469 """
323- from sshkeyboard import listen_keyboard
470+ if not sys .stdin .isatty ():
471+ raise RuntimeError (
472+ "interactive_shell requires an interactive terminal (stdin is "
473+ "not a TTY); use ShellSession/create_shell_session for "
474+ "programmatic access"
475+ )
324476
325477 try :
326478 cols , rows = os .get_terminal_size ()
@@ -337,40 +489,14 @@ def _reader():
337489 sys .stdout .buffer .write (data )
338490 sys .stdout .buffer .flush ()
339491
340- def _on_key_press (key : str ) -> None :
341- """Handle a key press event from sshkeyboard."""
342- if not session .connected :
343- return
344- if key == "enter" :
345- k = "\r \n "
346- elif key == "space" :
347- k = " "
348- elif key == "tab" :
349- k = "\t "
350- elif key == "up" :
351- k = "\x1b [A"
352- elif key == "right" :
353- k = "\x1b [C"
354- elif key == "down" :
355- k = "\x1b [B"
356- elif key == "left" :
357- k = "\x1b [D"
358- elif key == "backspace" :
359- k = "\x7f "
360- else :
361- k = key
362- session .send (f"\x00 { k } " .encode ("utf-8" ))
363-
364492 reader_thread = threading .Thread (target = _reader , daemon = True )
365493 reader_thread .start ()
366494
367495 try :
368- listen_keyboard (
369- on_press = _on_key_press ,
370- delay_second_char = 0 ,
371- delay_other_chars = 0 ,
372- lower = False ,
373- )
496+ if os .name == "nt" :
497+ _windows_input_loop (session )
498+ else :
499+ _posix_input_loop (session )
374500 except KeyboardInterrupt :
375501 pass
376502 finally :
0 commit comments