Selaa lähdekoodia

better control of python worker

Tomi Cvetic 5 vuotta sitten
vanhempi
sitoutus
d0d130937a

+ 123 - 32
backend/src/interfaces.js

@@ -1,4 +1,5 @@
 const fs = require('fs')
+const os = require('os')
 const md5 = require('md5')
 const { promisify } = require('util')
 const PythonWorker = require('./pythonWorker')
@@ -22,26 +23,37 @@ const typeDefs = `
 
   type Port {
     id: String!
-    device: String!
     interfaceName: String!
+    host: String!
+    device: String!
     name: String
     description: String
   }
 
+  type Worker {
+    pid: Int
+    killed: Boolean!
+    signalCode: String
+    exitCode: Int
+    spawnfile: String!
+    spawnargs: [String]!
+    error: Int!
+    data: Int!
+  }
+
   type Connection {
     id: ID!
     interfaceName: String!
+    host: String!
     device: String!
-  }
-
-  type Worker {
-    interfaceName: String!
-    workerScript: String!
+    workerInfo: Worker
   }
 
   type Interface {
     interfaceName: String!
+    host: String!
     workerScript: String!
+    workerInfo: Worker
     ports: [Port]!
     connections: [Connection]!
     options: [Option]!
@@ -49,17 +61,21 @@ const typeDefs = `
 
   extend type Query {
     interfaces(force: Boolean): [Interface]!
-    ports(interfaceName: String!, force: Boolean): [Port]!
+    ports(interfaceName: String, force: Boolean): [Port]!
     connections: [Connection]!
+    connection(id: ID!): Connection!
   }
 
   extend type Mutation {
     connect(interfaceName: String!, device: String!): Connection!
+    spawnWorker(id: ID!): Worker!
+    endWorker(id: ID!): Worker!
+    killWorker(id: ID!): Worker!
     connectionCommand(connectionId: ID!, type: String!, string: String!, options: String): String!
   }
 `
 
-async function findWorkers () {
+async function findWorkers() {
   // Find all files in ./python_workers that end in _worker.py
   const fileNames = await readdir(`${__dirname}/python_workers`)
   const workerFiles = fileNames.filter(fileName => fileName.includes('_worker.py'))
@@ -76,36 +92,46 @@ async function findWorkers () {
   })
 }
 
-async function findInterfaces () {
+async function findInterfaces() {
   // Try to identify workers if necessary
-  if (!state.workers.length) await findWorkers()
+  //if (!state.workers.length) 
+  await findWorkers()
 
   // Generate an interface for each worker
-  state.workers.forEach(worker => {
+  const workerPromises = state.workers.map(async worker => {
     const { workerScript, interfaceName } = worker
     // Skip existing interfaces
     if (state.interfaces.find(iface => iface.interfaceName === interfaceName)) return null
+    const pythonWorker = new PythonWorker(workerScript)
+    const { res, error } = await pythonWorker.spawn()
+    if (error) {
+      return
+    }
     state.interfaces.push({
       interfaceName,
       workerScript,
-      worker: new PythonWorker(workerScript),
+      worker: pythonWorker,
       ports: [],
       connections: [],
       options: []
     })
   })
+  await Promise.all(workerPromises)
 }
 
-async function interfaces (parent, args, ctx, info) {
+async function interfaces(parent, args, ctx, info) {
   const { force } = args
   // Try to identify interfaces if necessary
-  if (!state.interfaces.length || force) await findInterfaces()
+  //if (!state.interfaces.length || force) 
+  await findInterfaces()
 
   return state.interfaces.map(iface => {
-    const { workerScript, interfaceName } = iface
+    const { workerScript, interfaceName, worker } = iface
     return {
       interfaceName,
+      host: os.hostname(),
       workerScript,
+      workerInfo: workerInfo(worker, args, ctx, info),
       ports: ports(interfaceName, args, ctx, info),
       connections: connections(interfaceName, args, ctx, info),
       options: options(interfaceName, args, ctx, info)
@@ -113,12 +139,13 @@ async function interfaces (parent, args, ctx, info) {
   })
 }
 
-async function findPorts (interfaceName) {
+async function findPorts(interfaceName) {
   // Generate all ports for the interface
   const iface = state.interfaces.find(iface => iface.interfaceName === interfaceName)
 
-  const { data, error } = await iface.worker.send({ type: 'ports' })
-  if (error) throw new Error(JSON.stringify(error))
+  const { data, error, pythonError } = await iface.worker.send({ type: 'ports' })
+  if (error) throw new Error(error)
+  if (typeof pythonError !== "undefined") throw new Error(pythonError)
   data.forEach(port => {
     const id = port.name || port.device
     // Skip existing ports
@@ -126,6 +153,7 @@ async function findPorts (interfaceName) {
     const newPort = {
       id,
       interfaceName,
+      host: os.hostname(),
       ...port
     }
     state.ports.push(newPort)
@@ -133,7 +161,7 @@ async function findPorts (interfaceName) {
   })
 }
 
-async function ports (parent, args, ctx, info) {
+async function ports(parent, args, ctx, info) {
   const { force, interfaceName } = args
   const ifName = interfaceName || parent
 
@@ -148,14 +176,15 @@ async function ports (parent, args, ctx, info) {
   }
 }
 
-async function findOptions (interfaceName) {
+async function findOptions(interfaceName) {
   const iface = state.interfaces.find(iface => iface.interfaceName === interfaceName)
-  const { data, error } = await iface.worker.send({ type: 'options' })
-  if (error) throw new Error(JSON.stringify(error))
+  const { data, error, pythonError } = await iface.worker.send({ type: 'options' })
+  if (error) throw new Error(error)
+  if (typeof pythonError !== "undefined") throw new Error(pythonError)
   iface.options.push(...data)
 }
 
-async function options (parent, args, ctx, info) {
+async function options(parent, args, ctx, info) {
   const iface = state.interfaces.find(iface => iface.interfaceName === parent)
 
   // Try to find options if necessary
@@ -163,7 +192,21 @@ async function options (parent, args, ctx, info) {
   return iface.options
 }
 
-async function connections (parent, args, ctx, info) {
+function workerInfo(parent, args, ctx, info) {
+  const { killed, exitCode, signalCode, spawnargs, spawnfile, pid } = parent.pythonShell
+  return {
+    pid,
+    killed,
+    exitCode,
+    signalCode,
+    spawnfile,
+    spawnargs,
+    error: parent.error.length,
+    data: parent.data.length
+  }
+}
+
+async function connections(parent, args, ctx, info) {
   if (parent) {
     const iface = state.interfaces.find(iface => iface.interfaceName === parent)
     return iface.connections
@@ -172,40 +215,88 @@ async function connections (parent, args, ctx, info) {
   }
 }
 
-async function connect (parent, args, ctx, info) {
+async function connection(parent, args, context, info) {
+  const connection = state.connections.find(connection => connection.id === args.id)
+  return connection
+}
+
+async function connect(parent, args, ctx, info) {
   const { interfaceName, device } = args
   const iface = state.interfaces.find(iface => iface.interfaceName === interfaceName)
   const id = md5(interfaceName + device)
   if (iface.connections.find(connection => connection.id === id)) throw new Error('already connected.')
+  const pythonWorker = new PythonWorker(iface.workerScript)
+  const spawnData = await pythonWorker.spawn()
+  if (spawnData.error) throw new Error(spawnData.error)
   const connection = {
     id,
     device,
     interfaceName,
-    worker: new PythonWorker(iface.workerScript)
+    host: os.hostname(),
+    worker: pythonWorker,
+    workerInfo: (parent, args, context, info) => workerInfo(pythonWorker, args, context, info)
   }
-  const { error } = await connection.worker.send({ type: 'connect', device })
-  if (error) throw new Error(error)
+  const connectionData = await connection.worker.send({ type: 'connect', device })
+  if (spawnData.error) throw new Error(spawnData.error)
+  if (spawnData.pythonError) throw new Error(spawnData.pythonError)
   iface.connections.push(connection)
   state.connections.push(connection)
   return connection
 }
 
-async function connectionCommand (parent, args, ctx, info) {
+async function connectionCommand(parent, args, ctx, info) {
   const { connectionId, type, string, options } = args
   const connection = state.connections.find(connection => connection.id === connectionId)
-  const { data } = await connection.worker.send({ type, string, options })
+  const { data, error, pythonError } = await connection.worker.send({ type, string, options })
+  if (error) throw new Error(JSON.stringify(error))
+  if (typeof pythonError !== "undefined") throw new Error(pythonError)
   return data.response
 }
 
+//TODO Also find connections in interfaces.
+async function endWorker(parent, args, ctx, info) {
+  const { id } = args
+  const connection = state.connections.find(connection => connection.id === id)
+  const { data, error, pythonError } = await connection.worker.end()
+  console.log(data, error, pythonError)
+  if (error) throw new Error(JSON.stringify(error))
+  if (typeof pythonError !== "undefined") throw new Error(pythonError)
+  return connection.workerInfo
+}
+
+async function killWorker(parent, args, ctx, info) {
+  const { id } = args
+  const connection = state.connections.find(connection => connection.id === id)
+  const { data, error, pythonError } = await connection.worker.kill()
+  console.log(data, error, pythonError)
+  if (error) throw new Error(JSON.stringify(error))
+  if (typeof pythonError !== "undefined") throw new Error(pythonError)
+  return connection.workerInfo
+}
+
+async function spawnWorker(parent, args, ctx, info) {
+  const { id } = args
+  const connection = state.connections.find(connection => connection.id === id)
+  const { data, error, pythonError } = await connection.worker.spawn()
+  console.log(data, error, pythonError)
+  if (error) throw new Error(error)
+  if (typeof pythonError !== "undefined") throw new Error(error)
+  return connection.workerInfo
+}
+
 const resolvers = {
   Query: {
     interfaces,
     ports,
-    connections
+    connections,
+    connection
   },
   Mutation: {
     connect,
-    connectionCommand
+    connectionCommand,
+    killWorker,
+    endWorker,
+    spawnWorker
   }
 }
 

+ 105 - 26
backend/src/pythonWorker.js

@@ -1,36 +1,115 @@
-const { PythonShell } = require('python-shell')
+const { spawn } = require('child_process')
 const { Lock } = require('semaphore-async-await')
 
-const PYTHON_PATH = `${__dirname}/python_workers/venv/Scripts/python.exe`
 
-const pythonShellDefaultOptions = {
-  pythonPath: PYTHON_PATH,
-  mode: 'json'
-}
+function PythonWorker(workerScript, shellOptions) {
+  // create a lock for incomming commands
+  this.commandLock = new Lock()
+  // create a lock for python communication
+  this.pythonLock = new Lock()
 
-function PythonWorker (workerScript, shellOptions) {
-  this.toPythonLock = new Lock()
-  this.fromPythonLock = new Lock()
   this.data = []
+  this.error = []
   this.workerScript = workerScript
-  this.pythonShellOptions = { ...pythonShellDefaultOptions, shellOptions }
-  this.pythonShell = new PythonShell(this.workerScript, this.pythonShellOptions)
-  this.pythonShell.on('message', message => {
-    this.data.push(message)
-    this.fromPythonLock.release()
-  })
-  this.pythonShell.on('error', error => {
-    this.error = error
-  })
+
+  // The python shell is started with this.spawn
+  this.pythonShell = null
+
+  // Use send a command to the python worker.
+  // 
   this.send = async (command) => {
-    await this.toPythonLock.acquire()
-    await this.fromPythonLock.acquire()
-    this.pythonShell.send(command)
-    await this.fromPythonLock.acquire()
-    const res = this.data.pop()
-    this.fromPythonLock.release()
-    this.toPythonLock.release()
-    return res
+    if (this.pythonShell.killed) return { error: `process ${this.pythonShell.pid} already killed.` }
+    if (this.pythonShell.exitCode !== null) return { error: `process ${this.pythonShell.pid} already exited with code ${this.pythonShell.exitCode}` }
+    await this.commandLock.acquire()
+    await this.pythonLock.acquire()
+    this.pythonShell.stdin.write(
+      // Write the command as a JSON object. 
+      //!Don't forget the new-line
+      JSON.stringify(command) + '\n'
+    )
+    await this.pythonLock.acquire()
+    const pythonError = this.error.pop()
+    if (typeof pythonError !== "undefined") {
+      this.pythonLock.release()
+      this.commandLock.release()
+      return { pythonError }
+    }
+    const { data, error } = this.data.pop()
+    this.pythonLock.release()
+    this.commandLock.release()
+    return { data, error }
+  }
+  this.spawn = async () => {
+    if (this.pythonShell) {
+      if (!this.pythonShell.killed && (this.pythonShell.exitCode === null)) {
+        return { error: `process still running with pid = ${this.pythonShell.pid}` }
+      }
+    }
+    await this.commandLock.acquire()
+    await this.pythonLock.acquire()
+    this.pythonShell = spawn(
+      `${process.env.PWD}/${process.env.PYTHON_PATH}`,
+      [this.workerScript]
+    )
+    this.pythonShell.stdout.on('data', message => {
+      this.data.push(JSON.parse(message))
+      this.pythonLock.release()
+    })
+    this.pythonShell.stderr.on('data', error => {
+      this.error.push(JSON.parse(error))
+      this.pythonLock.release()
+    })
+    this.pythonShell.on('close', error => {
+      this.error.push(JSON.parse(error))
+      this.pythonLock.release()
+    })
+    await this.pythonLock.acquire()
+    const pythonError = this.error.pop()
+    if (typeof pythonError !== "undefined") {
+      this.pythonLock.release()
+      this.commandLock.release()
+      return { pythonError }
+    }
+    const { data, error } = this.data.pop()
+    this.pythonLock.release()
+    this.commandLock.release()
+    return { data, error }
+  }
+  this.end = async () => {
+    if (this.pythonShell.killed) return { error: `process ${this.pythonShell.pid} already killed.` }
+    if (this.pythonShell.exitCode !== null) return { error: `process ${this.pythonShell.pid} already exited with code ${this.pythonShell.exitCode}` }
+    await this.commandLock.acquire()
+    await this.pythonLock.acquire()
+    this.pythonShell.stdin.end()
+    await this.pythonLock.acquire()
+    console.log(this.error, this.data)
+    const pythonError = this.error.pop()
+    if (typeof pythonError !== "undefined") {
+      this.pythonLock.release()
+      this.commandLock.release()
+      console.log('checkpoint')
+      return { pythonError }
+    }
+    const { data, error } = this.data.pop()
+    this.pythonLock.release()
+    this.commandLock.release()
+    return { data, error }
+  }
+  this.kill = async () => {
+    if (this.pythonShell.killed) return { error: `process ${this.pythonShell.pid} already killed.` }
+    if (this.pythonShell.exitCode !== null) return { error: `process ${this.pythonShell.pid} already exited with code ${this.pythonShell.exitCode}` }
+    await this.commandLock.acquire()
+    await this.pythonLock.acquire()
+    const pythonError = this.error.pop()
+    if (typeof pythonError !== "undefined") {
+      this.pythonLock.release()
+      this.commandLock.release()
+      return { pythonError }
+    }
+    const { data, error } = this.data.pop()
+    this.pythonLock.release()
+    this.commandLock.release()
+    return { data, error }
   }
 }
 

+ 18 - 7
backend/src/python_workers/README.md

@@ -2,16 +2,27 @@
 
 A Python worker is expected to do the following:
 
-Interpret information commands
-  * `option` returns possible options for the interface
+1. Send a `{"data": "[NAME] ready"}` JSON object when ready to process commands.
+2. Run a REPL (read-evaluate-print loop) that accepts and prints JSON formatted strings
+
+The JSON object shall contain
+  * `type` type of instruction
+  * `command` (optional) 
+
+Instruction types
+  * `options` returns possible options for the interface
   * `ports` returns a list of available ports
+  * `commands` returns a list of accepted commands
+  * `execute` executes a command 
 
-Interpret operation commands
-  * `read`
-  * `write`
-  * `ask`
+The execute type expects
+  * `command` the command from the list of `commands` to run.
+  * `args` a list of arguments
 
-The worker accepts JSON strings and returns JSON strings
+Execute shall implement at least the following commands
+  * `read` to read data from the worker
+  * `write` to write data to the worker
+  * `ask` to write data and immediately read a response
 
 The response JSON contains at least one of the following sections:
 * `data` for the results

+ 8 - 5
backend/src/python_workers/serial_worker.py

@@ -13,9 +13,11 @@ class SerialWorker:
     """
     OPTIONS = [
         {"name": "port", "type": "String"},
-        {"name": "baudrate", "type": "Int", "values": [50, 75, 110, 134, 150, 200, 300, 600, 1200, 1800, 2400, 4800, 9600, 19200, 38400, 57600, 115200]},
+        {"name": "baudrate", "type": "Int", "values": [
+            50, 75, 110, 134, 150, 200, 300, 600, 1200, 1800, 2400, 4800, 9600, 19200, 38400, 57600, 115200]},
         {"name": "bytesize", "type": "Int", "values": [5, 6, 7, 8]},
-        {"name": "parity", "type": "String", "values": ["None", "Even", "Odd", "Mark", "Space"]},
+        {"name": "parity", "type": "String", "values": [
+            "None", "Even", "Odd", "Mark", "Space"]},
         {"name": "timeout", "type": "Float"},
         {"name": "write_timeout", "type": "Float"},
         {"name": "xonxoff", "type": "Bool"},
@@ -62,7 +64,7 @@ class SerialWorker:
         return {
             "device": device
         }
-            
+
     def write(self, string):
         encodedString = (string + self.newLine).encode('ascii')
         self.connection.write(encodedString)
@@ -99,6 +101,7 @@ class SerialWorker:
 
 
 worker = SerialWorker()
+print(json.dumps({"data": "Serial ready"}), flush=True)
 
 for line in sys.stdin:
     try:
@@ -116,6 +119,6 @@ for line in sys.stdin:
             res = worker.write(command['string'])
         elif command['type'] == 'ask':
             res = worker.ask(command['string'])
-        print(json.dumps({ "data": res }), flush=True)
+        print(json.dumps({"data": res}), flush=True)
     except Exception as error:
-        print(handle_exception(error), flush=True)
+        print(handle_exception(error), flush=True)

+ 121 - 0
backend/src/python_workers/test_worker.py

@@ -0,0 +1,121 @@
+import sys
+import time
+import random
+import json
+from utils import handle_exception
+
+
+class TestClass:
+    def __init__(self, device):
+        self.timeout = 2
+        self.buffer = None
+
+    def write(self, string):
+        self.buffer = string
+
+    def read(self):
+        buffer = self.buffer
+        self.buffer = None
+        if buffer is None:
+            time.sleep(self.timeout)
+            raise Exception('Timeout!')
+        if buffer == '*IDN?':
+            return "TEST DEVICE, V2.3, S/N:31572394792"
+        if buffer == 'SCALAR?':
+            return "{:.4f} DCV".format(3.3*random.random())
+        if buffer == 'ARRAY?':
+            return "CURVE? {}".format(','.join([str(random.randint(0, 255)) for i in range(50)]))
+        raise Exception('Command not found!')
+
+    def ask(self, string):
+        self.write(string)
+        return self.read()
+
+    def close(self):
+        pass
+
+
+class TestWorker:
+    OPTIONS = [
+        {"name": "port", "type": "String"},
+        {"name": "baudrate", "type": "Int", "values": [
+            50, 75, 110, 134, 150, 200, 300, 600, 1200, 1800, 2400, 4800, 9600, 19200, 38400, 57600, 115200]},
+    ]
+
+    connection = None
+
+    @staticmethod
+    def ports():
+        driverPorts = [
+            {'name': 'Port 22', 'device': 22},
+            {'name': 'Port 80', 'device': 80},
+        ]
+
+        ports = []
+
+        for port in driverPorts:
+            obj = {
+                "name": port['name'],
+                "device": port['device'],
+            }
+            ports.append(obj)
+
+        return ports
+
+    def timeout(self, value):
+        self.connection.timeout = value
+        return {
+            "timeout": value
+        }
+
+    def connect(self, device):
+        self.connection = TestClass(device)
+        return {
+            "device": device
+        }
+
+    def write(self, string):
+        self.connection.write(string)
+        return {
+            "string": string
+        }
+
+    def read(self):
+        response = self.connection.read()
+        return {
+            "response": response
+        }
+
+    def ask(self, string):
+        response = self.connection.ask(string)
+        return {
+            "response": response
+        }
+
+    def close(self):
+        self.connection.close()
+        return {}
+
+
+worker = TestWorker()
+print(json.dumps({"data": "Testworker ready"}), flush=True)
+
+for line in sys.stdin:
+    try:
+        command = json.loads(line)
+        if command['type'] == 'ports':
+            data = TestWorker.ports()
+        elif command['type'] == 'options':
+            data = TestWorker.OPTIONS
+        elif command['type'] == 'connect':
+            options = command['options'] if 'options' in command else {}
+            data = worker.connect(command['device'], **options)
+        elif command['type'] == 'read':
+            data = worker.read()
+        elif command['type'] == 'write':
+            data = worker.write(command['string'])
+        elif command['type'] == 'ask':
+            data = worker.ask(command['string'])
+        print(json.dumps({"data": data}), flush=True)
+    except Exception as error:
+        print(handle_exception(error), flush=True)

+ 48 - 46
backend/src/python_workers/usbtmc_worker.py

@@ -2,7 +2,7 @@ import sys
 import json
 import usbtmc
 from utils import handle_exception
- 
+
 
 class USBTMCWorker:
     OPTIONS = []
@@ -11,60 +11,60 @@ class USBTMCWorker:
 
     @staticmethod
     def ports():
-            driverPorts = usbtmc.list_devices()
-
-            ports = []
-
-            for port in driverPorts:
-                obj = {
-                    "device": ':'.join([str(port.idVendor), str(port.idProduct)]),
-                    "name": port.product,
-                    "address": port.address,
-                    "bus": port.bus,
-                    "manufacturer": port.manufacturer,
-                    "product": port.product,
-                    "serial_number": port.serial_number,
-                    "idProduct": port.idProduct,
-                    "idVendor": port.idVendor
-                }
-                ports.append(obj)
-
-            return ports
+        driverPorts = usbtmc.list_devices()
 
-    def timeout(self, value):
-            self.connection.timeout = value
-            return {
-                "timeout": value
+        ports = []
+
+        for port in driverPorts:
+            obj = {
+                "device": ':'.join([str(port.idVendor), str(port.idProduct)]),
+                "name": port.product,
+                "address": port.address,
+                "bus": port.bus,
+                "manufacturer": port.manufacturer,
+                "product": port.product,
+                "serial_number": port.serial_number,
+                "idProduct": port.idProduct,
+                "idVendor": port.idVendor
             }
+            ports.append(obj)
+
+        return ports
+
+    def timeout(self, value):
+        self.connection.timeout = value
+        return {
+            "timeout": value
+        }
 
     def connect(self, device):
-            [idVendor, idProduct] = device.split(':')
-            self.connection = usbtmc.Instrument(int(idVendor), int(idProduct))
-            return {
-                    "device": device
-                }
-            
+        [idVendor, idProduct] = device.split(':')
+        self.connection = usbtmc.Instrument(int(idVendor), int(idProduct))
+        return {
+            "device": device
+        }
+
     def write(self, string):
-            self.connection.write(string)
-            return {
-                    "string": string
-                }
+        self.connection.write(string)
+        return {
+            "string": string
+        }
 
     def read(self):
-            response = self.connection.read(string)
-            return {
-                    "response": response
-                }
+        response = self.connection.read()
+        return {
+            "response": response
+        }
 
     def ask(self, string):
-      response = self.connection.ask(string)
-      return {
-        "response": response
-      }
+        response = self.connection.ask(string)
+        return {
+            "response": response
+        }
 
     def close(self):
-            self.connection.close()
-            return {}
+        self.connection.close()
+        return {}
 
     def __exit__(self, exc_type, exc_value, traceback):
         if self.connection is not None:
@@ -72,7 +72,9 @@ class USBTMCWorker:
             del self.connection
             self.connection = None
 
+
 worker = USBTMCWorker()
+print(json.dumps({"data": "USBTMC ready"}), flush=True)
 
 for line in sys.stdin:
     try:
@@ -90,6 +92,6 @@ for line in sys.stdin:
             res = worker.write(command['string'])
         elif command['type'] == 'ask':
             res = worker.ask(command['string'])
-        print(json.dumps({ "data": res }), flush=True)
+        print(json.dumps({"data": res}), flush=True)
     except Exception as error:
-        print(handle_exception(error), flush=True)
+        print(handle_exception(error), flush=True)

+ 11 - 10
frontend/components/Connection.js

@@ -1,7 +1,7 @@
 import styled from 'styled-components'
 import gql from 'graphql-tag'
 import { Mutation } from 'react-apollo'
-import {INTERFACE_LIST} from './InterfaceList'
+import { INTERFACE_LIST } from './InterfaceList'
 
 const StyledConnection = styled.div`
   fieldset {
@@ -37,15 +37,15 @@ class Connection extends React.Component {
   render() {
     const { id, device, interfaceName } = this.props.data
     return (
-      <Mutation 
-      mutation={CONNECTION_COMMAND} 
-      variables={{
-        connectionId: this.props.data.id, 
-        type: 'ask',
-        string: this.state.command
-      }}
-      refetchQueries={[{query: INTERFACE_LIST}]}>
-        {(connectionCommand, {data, error, loading}) => (
+      <Mutation
+        mutation={CONNECTION_COMMAND}
+        variables={{
+          connectionId: this.props.data.id,
+          type: 'ask',
+          string: this.state.command
+        }}
+        refetchQueries={[{ query: INTERFACE_LIST }]}>
+        {(connectionCommand, { data, error, loading }) => (
           <StyledConnection>
             <h1>Connection</h1>
             <fieldset>
@@ -54,6 +54,7 @@ class Connection extends React.Component {
             </fieldset>
             <button type='submit' onClick={connectionCommand} disabled={loading}>Send</button>
             <textarea id='response' value={data && data.connectionCommand} readOnly={true} />
+            <textarea id='error' value={error} readOnly={true} />
           </StyledConnection>
         )}
       </Mutation>

+ 18 - 0
frontend/components/PythonWorker.js

@@ -0,0 +1,18 @@
+import { Query } from 'react-apollo'
+import gql from 'graphql-tag'
+
+const WORKER_QUERY = gql`
+  query WORKER_QUERY(id: ID!) {
+    connections {
+      worker {
+
+      }
+    }
+  }
+`
+
+const PythonWorker = props => (
+
+)
+
+export default PythonWorker