20 junio 2013

El instalador automatizado de DrGPIO vía SSH

Una de las preguntas que varias personas me han estado haciendo últimamente es ¿Cómo funciona el instalador automatizado de WebIOPi en DRGPIO?

En esta ocasión voy a dedicarme a explicar de manera muy breve como funciona el instalador de DrGPIO utilizando algunos conceptos un poco "abstractos" pero que simplifican muchísimo la programación.

Luego de terminar esta entrada podrán hacer uso del código fuente para generar sus propios instaladores o para controlar remotamente máquinas utilizando Android.


Acabando con las expectativas


En esta entrada no voy a detallar el procedimiento para realizar conexiones SSH desde Android/Java ya que lo he explicado anteriormente en Estableciendo conexiones SSH con Java (y Android).

Habiendo dicho esto revisemos un par de conceptos un poco más "abstractos"...

Generando Expectativas


El instalador de DrGPIO funciona literalmente haciéndose "expectativas" concretas sobre la salida que generan los comandos en la consola.

Lo anterior es más fácil de entender si lo explico con un ejemplo:

El comando "du" lista el tamaño de los archivos en un directorio y si agregamos el parámetro "-h" nos muestra la salida en una forma amigable y de fácil lectura y si colocamos el parámetro "-c" entonces nos muestra una lista con los archivos y al final de su ejecución nos mostrará el tamaño total de los archivos en el directorio.

Por ejemplo, si yo ejecuto en la consola:
du -ch .


Tengo la expectativa de obtener una respuesta como la siguiente:
50K     Archivo1.txt
25K     Archivo2.txt
5K      Archivo3.txt
75K     Total

En otras palabras yo estoy a la  "expectativa"  de obtener la línea con la palabra "Total".

La idea del instalador es enviar una serie de comandos y mantenerse esperando ciertas respuestas. De manera adicional podemos implementar un "watchdog", que se encargará de esperar por cierto tiempo. Esto es útil cuando por ejemplo un comando se queda bloqueado. El evento de watchdog nos permitirá ejecutar un código específico en caso de que, por ejemplo, la comunicación falle.

Programando en Android


Lo primero que debemos hacer es crear un proyecto simple en Android. Si no saben como hacerlo pueden seguir las instrucciones en AndroiSensei.

Luego descarga el código de libGPIO de github de la siguiente dirección y coloca las fuentes en el directorio "src" de tu proyecto.


Luego descarga la biblioteca jsch de la siguiente dirección:


Y copia el archivo .jar a la carpeta "libs" de tu proyecto de aplicación Android.

Lo siguiente es crear una sencilla interfaz de usuario. Para este ejemplo la interfaz que utilizaré tiene únicamente un sencillo TextView, un ProgressBar y un Button.


Lo siguiente es asociar el evento "click" al botón que se ha creado en la interfaz. Podemos asociar el evento en el método "onCreate" de la clase "MainActivity".
 protected void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);
  setContentView(R.layout.activity_main);

  final MainActivity self = this;

  ((Button) this.findViewById(R.id.button1)).setOnClickListener(
    new OnClickListener() {

     @Override
     public void onClick(View v) {
      
     }
     
    }
    );
  
 }

Notarán que declaro "final MainActivity self". Esta variable me servirá más adelante para acceder a los elementos de la clase. La interfaz de usuario se actualiza en un hilo diferente y si intentáramos modificar los elementos de la interfaz directamente nos generaría un error. La variable "self" nos ayudará entonces permitiéndonos acceder a los métodos y variables de la clase que quedan "escondidos" del hilo de la interfaz.

Lo siguiente es definir una variable que almacene nuestras "Expectativas", las expectativas si recuerdan son los textos que esperamos recibir en la respuesta de nuestros comandos.
 private ArrayList<xpectation> exps;

Esta variable la definimos en nuestra clase MainActivity para que pueda ser accedida por todos los métodos.

Luego debemos implementar la interfaz "Expectator" (Espectador). Esto nos permitirá recibir los eventos cada vez que una expectación ha sido satisfecha.
public class MainActivity extends Activity implements Expectator { 
... 
}

Al implementar esta interfaz debemos implementar los métodos "fulfill" y "onFail".
public class MainActivity extends Activity implements Expectator { 
...
 @Override
 public void fulfill(Expectation exp) {
 }

 @Override
 public void onFailed() {
 }
}

El método fulfill es llamado cada vez que una expectativa es satisfecha y onFail es llamado cuando se detecta una excepción o en su defecto cuando ha pasado el tiempo de espera configurado para el watchdog.

En la acción del botón vamos a definir una nueva conexión con SSHClient pasando como parámetros el host al que deseamos conectarnos, el usuario y el password:
...
     public void onClick(View v) {

      SSHClient client = new SSHClient(
        "10.48.80.105", // Host
        "testuser", // Usuario
        "testuser"); // Password

En el siguiente paso hacemos una lista de "expectativas" con las salidas que esperamos de la consola de comandos. Para este ejemplo utilizaré "porcentajes" estos no son calculados en ningún momento, simplemente se imprimen en pantalla, la tarea que ejecutaremos mostrará a su salida el porcentaje que coloquemos:
      exps = new ArrayList<expectation>();

      exps.add(new Expectation("10%"));
      exps.add(new Expectation("20%"));
      exps.add(new Expectation("30%"));
      exps.add(new Expectation("40%"));
      exps.add(new Expectation("50%"));
      exps.add(new Expectation("60%"));
      exps.add(new Expectation("70%"));
      exps.add(new Expectation("80%"));
      exps.add(new Expectation("90%"));
      exps.add(new Expectation("100%"));

Luego ponemos en cola de espera los comandos que queremos que se ejecuten, Estos serán enviados a la consola uno luego del otro. Tomen en consideración que aunque todos serán enviados de una sola vez estos serán ejecutados en el orden que los coloquemos en esta lista.
      client.queueCommand("pause 2; echo \"10%\";");
      client.queueCommand("pause 2; echo \"20%\";");
      client.queueCommand("pause 2; echo \"30%\";");
      client.queueCommand("pause 2; echo \"40%\";");
      client.queueCommand("pause 2; echo \"50%\";");
      client.queueCommand("pause 2; echo \"60%\";");
      client.queueCommand("pause 2; echo \"70%\";");
      client.queueCommand("pause 2; echo \"80%\";");
      client.queueCommand("pause 2; echo \"90%\";");
      client.queueCommand("pause 2; echo \"100%\";");

El comando "pause 2;" esperará dos segundos y el comando "echo %" mostrará en la consola el porcentaje correspondiente.

Lo siguiente es establecer algunas configuraciones necesarias. Primero indicaremos al cliente nuestras "expectativas", luego establecemos que la clase MainActivity recibirá las notificaciones de las expectativas satisfechas y por último el tiempo límite que deberá esperar el "watchdog" antes de dar por fallida la ejecución del comando.
      client.setExpectations(exps);
      client.setExpectator(self);
      client.setWatchdogTimeout(3000); // Timeout en milisegundos

Para iniciar la conexión del cliente tenemos que crear un nuevo hilo de ejecución.
      Thread t = new Thread(client);

      t.start();
      

      self.runOnUiThread(new Runnable() {

       @Override
       public void run() {
        ((Button) self.findViewById(R.id.button1))
          .setEnabled(false);
       }

      });

Antes de finalizar la rutina desactivamos el botón para evitar que se genere una conexión paralela.

El código que recibe las expectativas satisfechas se encarga de actualizar el ProgressBar, para este caso no necesitamos revisar el contenido de la expectativa ya que el orden se corresponde con el porcentaje.
 public void fulfill(Expectation exp) {
  // Esta función se llama al satisfacerse una expectativa en específico.
  int i = -1;
  final MainActivity self = this;

  // Para las expectativas revisamos la posición en el ArrayList para
  // identificar
  // El nivel que ha sido completado. Se pueden usar marcadores con "echo"
  // para
  // llevar el registro del porcentaje que se ha realizado.
  if ((i = this.exps.indexOf(exp)) != -1) {
   final int idx = i;
   runOnUiThread(new Runnable() {
    @Override
    public void run() {
     ((ProgressBar) self.findViewById(R.id.progressBar1))
       .setProgress((idx + 1) * 10);
    }
   });
  }
  // Si se completa la última expectativa en lista cambiamos el texto del
  // botón y la accion
  // asociada al mismo
  if (this.exps.indexOf(exp) == (this.exps.size() - 1)) {
   self.runOnUiThread(new Runnable() {

    @Override
    public void run() {
     ((TextView) self.findViewById(R.id.textView1))
       .setText("Completado");
    }

   });
   changeButtonBehaviour();
  }
 }

La función "changeButtonBehaviour" se encarga de modificar el comportamiento del botón de la aplicación. Al inicio asociamos este botón a iniciar el cliente pero ahora cuando se ha finalizado la ejecución nos interesa que nos sirva para salir de la aplicación:
 private void changeButtonBehaviour() {
  final MainActivity self = this;
  self.runOnUiThread(new Runnable() {

   @Override
   public void run() {
    ((Button) self.findViewById(R.id.button1)).setEnabled(true);
    ((Button) self.findViewById(R.id.button1)).setText("Salir");
   }

  });
  ((Button) this.findViewById(R.id.button1))
    .setOnClickListener(new OnClickListener() {

     @Override
     public void onClick(View v) {
      self.finish();
     }

    });

 }

Por último pero no menos importante, es el código que maneja los "fallos" esta función se recibe cada vez que se encuentra una excepción o si el tiempo límite de ejecución se supera:
 @Override
 public void onFailed() {
  // TODO Auto-generated method stub
  final MainActivity self = this;
  self.runOnUiThread(new Runnable() {

   @Override
   public void run() {
    ((TextView) self.findViewById(R.id.textView1))
      .setText("Se ha detectado un error");
   }

  });
  changeButtonBehaviour();
 }

Hasta este punto no queda nada más que compilar el programa y ejecutarlo.

Algunas consideraciones a tomar en cuenta

Todos los comandos que he ejecutado no son "interactivos" es decir que no esperan una respuesta del usuario para continuar su ejecución. Si desean utilizar comandos interactivos pueden modificar el código fuente de la clase SSHClient y pueden utilizar el "BufferedWriter" dentro de la clase para enviar respuestas a los comandos.

Un ejemplo de manejo de comandos interactivos es la del código de la instalación remota de WebIOPi en DrGPIO:
package co.teubi.raspberrypi.sshtool;

import com.jcraft.jsch.*;

import java.io.*;
import java.util.ArrayList;
import java.util.Timer;
import java.util.TimerTask;
import co.teubi.abstractions.Expectation;
import co.teubi.abstractions.Expectator;
import co.teubi.abstractions.io.InputStreamExpectations;

public class SSHInstaller implements Expectator,Runnable {
 

 private Timer watchdog;

 private JSch jsch;
 private ChannelShell shell;
 private Session ses;

 private BufferedWriter bw;
 private InputStreamExpectations sshInputStream;
 
 private String host;
 private String username;
 private String password;

 private void sendCommand(String command) {
  try {
   bw.append(command);
   bw.newLine();
   bw.flush();
  } catch (IOException e) {
   onFailed();
  }
 }

 private void startWatchdog() {
  if (watchdog != null) {
   watchdog.cancel();
  }
  watchdog = new Timer();
  watchdog.schedule((new TimerTask() {

   public void run() {
    onFailed();
   }

  }), 120000);
 }

 public SSHInstaller(String host,String username,String password) {
  this.host = host;
  this.username = username;
  this.password = password;
  watchdog = new Timer();
  jsch = new JSch();
  this.expectator = new Expectator() {
   @Override
   public void fulfill(Expectation exp) {
   }

   @Override
   public void onFailed() {
   }

  };
 }
 
 private Expectator expectator;

 public void setExpectator(Expectator exp) {
  this.expectator = exp;
 }

 public Expectator getExpectator() {
  return this.expectator;
 }

 public ArrayList getExpectations() {
  return this.sshInputStream.getExpectations();
 }
 
 public void cancel() {
  if(shell!=null) {
   shell.disconnect();
  }
  if(ses!=null) {
   ses.disconnect();
  }
 }

 @Override
 public void fulfill(Expectation exp) {
  if (exp == sshInputStream.getExpectations().get(0)) {
   sendCommand("rm WebIOPi-0.5.3.tar.gz");
   sendCommand("wget http://webiopi.googlecode.com/files/WebIOPi-0.5.3.tar.gz");
   startWatchdog();
  }
  if (exp == sshInputStream.getExpectations().get(1)) {
   sendCommand("tar -xzf WebIOPi-0.5.3.tar.gz");
   sendCommand("if [ -d WebIOPi-0.5.3 ]; then echo \"OKWebIOPi\";fi");
   startWatchdog();
  }
  if (exp == sshInputStream.getExpectations().get(2)) {
   sendCommand("cd WebIOPi-0.5.3");
   sendCommand("sudo ./setup.sh");
   startWatchdog();
  }
  if (exp == sshInputStream.getExpectations().get(3)) {
   sendCommand("sudo python -m webiopi 8000 &");
   startWatchdog();
  }
  if (exp == sshInputStream.getExpectations().get(4)) {
   System.out.println("Installation complete");
   watchdog.cancel();
   shell.disconnect();
   ses.disconnect();
  }
  this.expectator.fulfill(exp);
  if (sshInputStream.getExpectations().contains(exp)) {
   System.out.println("Progress: "
     + ((sshInputStream.getExpectations().indexOf(exp) + 1) / (sshInputStream.getExpectations()
       .size() * 1.0)) * 100 + "%");
  }
 }

 @Override
 public void onFailed() {
  if(shell!=null) {
   shell.disconnect();
  }
  if(ses!=null) {
   ses.disconnect();
  }
  System.out.println("Installation failed.");
  this.expectator.onFailed();
 }

 @Override
 public void run() {
  try {
   JSch.setConfig("StrictHostKeyChecking", "no");
   ses = jsch.getSession(this.username, this.host);
   ses.setPassword(this.password);

   ses.connect();

   // Trying
   shell = (ChannelShell) ses.openChannel("shell");

   PipedInputStream pin = new PipedInputStream();
   PipedOutputStream pout = new PipedOutputStream(pin);

   PipedOutputStream pout2 = new PipedOutputStream();
   PipedInputStream pin2 = new PipedInputStream(pout2);

   shell.setInputStream(pin2);
   shell.setOutputStream(pout);

   sshInputStream = new InputStreamExpectations(pin);
   sshInputStream.setExpectator(this);
   sshInputStream.expects(new Expectation("Linux raspberrypi 3.2.27+"));
   sshInputStream.expects(new Expectation("WebIOPi-0.5.3.tar.gz' saved"));
   sshInputStream.expects(new Expectation("OKWebIOPi"));
   sshInputStream.expects(new Expectation(
     "WebIOPi successfully installed"));
   sshInputStream.expects(new Expectation(
     "WebIOPi/Python2/0.5.3 HTTP Server started"));

   Thread t = new Thread(sshInputStream);
   t.start();


   bw = new BufferedWriter(new OutputStreamWriter(pout2));

   shell.connect();
   this.expectator.fulfill(new Expectation("connected"));
   startWatchdog();

   t.join(); // Wait reader thread for finish
  } catch (JSchException e) {
   System.out.println(e.getMessage());
   onFailed();
  } catch (IOException e) {
   System.out.println(e.getMessage());
   onFailed();
  } catch (InterruptedException e) {
   System.out.println(e.getMessage());
   onFailed();
  }
  
 }

}

Para el código anterior la secuencia de comandos es la siguiente:
rm WebIOPi-0.5.3.tar.gz
wget http://webiopi.googlecode.com/files/WebIOPi-0.5.3.tar.gz
tar -xzf WebIOPi-0.5.3.tar.gz
if [ -d WebIOPi-0.5.3 ]; then echo "OKWebIOPi";fi
cd WebIOPi-0.5.3
sudo ./setup.sh
sudo python -m webiopi 8000 &

Y las expectativas son las siguientes:
Linux raspberrypi 3.2.27+
WebIOPi-0.5.3.tar.gz' saved
OKWebIOPi
WebIOPi successfully installed
WebIOPi/Python2/0.5.3 HTTP Server started

Y esto es todo... Ahora pueden crear su propio control remoto de cualquier Linux que les permita conectarse vía SSH.

Espero esto les haya sido de utilidad. ¡Hasta la próxima!

Más información y recursos

No hay comentarios: