[IMP] add a proxy to communicate with the kardex server

This commit is contained in:
Alexandre Fayolle
2019-11-14 10:57:42 +01:00
committed by Guewen Baconnier
parent bbeb506db3
commit 86e9e62306
19 changed files with 458 additions and 43 deletions

View File

@@ -1 +1,2 @@
from . import models
from . import controllers

View File

@@ -34,6 +34,7 @@
'views/stock_vertical_lift_templates.xml',
'views/shuttle_screen_templates.xml',
'security/ir.model.access.csv',
'data/ir_sequence.xml',
],
'installable': True,
'development_status': 'Alpha',

View File

@@ -0,0 +1 @@
from . import main

View File

@@ -0,0 +1,20 @@
import logging
import os
from odoo import http
from odoo.http import request
_logger = logging.getLogger(__name__)
class VerticalLiftController(http.Controller):
@http.route(['/vertical-lift'], type='http', auth='public', csrf=False)
def vertical_lift(self, answer, secret):
if secret == os.environ.get('VERTICAL_LIFT_SECRET', ''):
rec = request.env['vertical.lift.command'].sudo().record_answer(
answer
)
return str(rec.id)
else:
_logger.error('secret mismatch: %r != %r', secret, os.environ.get('VERTICAL_LIFT_SECRET', ''))
raise http.AuthenticationError()

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record model="ir.sequence" id="sequence_kardex_command">
<field name="name">Vertical Lift Commands</field>
<field name="code">vertical.lift.command</field>
<field name="prefix">L</field>
<field name="padding">6</field>
<field name="company_id" eval="False"/>
</record>
</data>
</odoo>

View File

@@ -8,3 +8,4 @@ from . import stock_location
from . import stock_move
from . import stock_move_line
from . import stock_quant
from . import vertical_lift_command

View File

@@ -127,7 +127,7 @@ class StockLocation(models.Model):
)
return message
else:
return super()._hardware_vertical_lift_tray_payload(cell_location)
raise NotImplemented()
def fetch_vertical_lift_tray(self, cell_location=None):
"""Send instructions to the vertical lift hardware

View File

@@ -0,0 +1,50 @@
# Copyright 2019 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from odoo import api, exceptions, fields, models
_logger = logging.getLogger(__name__)
class VerticalLiftCommand(models.Model):
_name = 'vertical.lift.command'
_order = 'shuttle_id, name desc'
_description = "commands sent to the shuttle"
@api.model
def _default_name(self):
return self.env['ir.sequence'].next_by_code('vertical.lift.command')
name = fields.Char(
'Name', default=_default_name, required=True, index=True
)
command = fields.Char(required=True)
answer = fields.Char()
error = fields.Char()
shuttle_id = fields.Many2one('vertical.lift.shuttle', required=True)
@api.model
def record_answer(self, answer):
name = self._get_key(answer)
record = self.search([('name', '=', name)], limit=1)
if not record:
_logger.error('unable to match answer to a command: %r', answer)
raise exceptions.UserError('Unknown record %s' % name)
record.answer = answer
record.shuttle_id._hardware_response_callback(record)
return record
def _get_key(self, answer):
key = answer.split('|')[1]
return key
@api.model_create_multi
@api.returns('self', lambda value: value.id)
def create(self, vals_list):
for values in vals_list:
if "name" not in values:
name = self._get_key(values.get('command'))
if name:
values["name"] = name
return super().create(vals_list)

View File

@@ -38,7 +38,10 @@ class VerticalLiftShuttle(models.Model):
use_tls = fields.Boolean(
help="set this if the server expects TLS wrapped communication"
)
command_ids = fields.One2many(
'vertical.lift.command', 'shuttle_id',
string="Hardware commands"
)
_sql_constraints = [
(
"location_id_unique",
@@ -81,14 +84,21 @@ class VerticalLiftShuttle(models.Model):
If in hardware is 'simulation' then display a simple message.
Otherwise defaults to connecting to server:port using a TCP socket
(optionnally wrapped with TLS) and sending the payload, then waiting
for a response and disconnecting.
(optionnally wrapped with TLS) and sending the payload.
:param payload: a bytes object containing the payload
"""
self.ensure_one()
_logger.info('send %r', payload)
command_values = {
'shuttle_id': self.id,
'command': payload.decode(),
}
self.env['vertical.lift.command'].sudo().create(
command_values
)
if self.hardware == "simulation":
self.env.user.notify_info(message=payload,
title=_("Lift Simulation"))
@@ -102,28 +112,18 @@ class VerticalLiftShuttle(models.Model):
offset += size
if offset >= len(payload) or not size:
break
response = self._hardware_recv_response(conn)
_logger.info('recv %r', response)
return self._check_server_response(payload, response)
finally:
self._hardware_release_server_connection(conn)
def _hardware_recv_response(self, conn):
"""Default implementation expects the remote server to close()
the socket after sending the reponse.
Override to match the protocol implemented by the hardware.
def _hardware_response_callback(self, command):
"""should be called when a response is received from the hardware
:param conn: a socket connected to the server
:return: the response sent by the server, as a bytes object
:param response: a string
"""
response = b''
chunk = True
while chunk:
chunk = conn.recv(1024)
response += chunk
return response
success = self._check_server_response(command)
self._send_notification_refresh(success)
def _check_server_response(self, payload, response):
def _check_server_response(self, command):
"""Use this to check if the response is a success or a failure
:param payload: the payload sent
@@ -214,7 +214,7 @@ class VerticalLiftShuttle(models.Model):
self.mode = "inventory"
return self.action_open_screen()
def _send_notification_refresh(self):
def _send_notification_refresh(self, success):
"""Send a refresh notification to the current opened screen
The form controller on the front-end side will instantaneously
@@ -226,7 +226,8 @@ class VerticalLiftShuttle(models.Model):
The method is private only to prevent xml/rpc calls to
interact with the screen.
"""
self._operation_for_mode._send_notification_refresh()
# XXX do we want to do something special in the notification?
self._operation_for_mode()._send_notification_refresh()
class VerticalLiftShuttleManualBarcode(models.TransientModel):

View File

@@ -5,3 +5,4 @@ access_vertical_lift_operation_pick_stock_user,access_vertical_lift_operation_pi
access_vertical_lift_operation_put_stock_user,access_vertical_lift_operation_put stock user,model_vertical_lift_operation_put,stock.group_stock_user,1,1,1,1
access_vertical_lift_operation_put_line_stock_user,access_vertical_lift_operation_put_line stock user,model_vertical_lift_operation_put_line,stock.group_stock_user,1,1,1,1
access_vertical_lift_operation_inventory_stock_user,access_vertical_lift_operation_inventory stock user,model_vertical_lift_operation_inventory,stock.group_stock_user,1,1,1,1
access_vertical_lift_command,vertical_lift_command,model_vertical_lift_command,base.group_user,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
5 access_vertical_lift_operation_put_stock_user access_vertical_lift_operation_put stock user model_vertical_lift_operation_put stock.group_stock_user 1 1 1 1
6 access_vertical_lift_operation_put_line_stock_user access_vertical_lift_operation_put_line stock user model_vertical_lift_operation_put_line stock.group_stock_user 1 1 1 1
7 access_vertical_lift_operation_inventory_stock_user access_vertical_lift_operation_inventory stock user model_vertical_lift_operation_inventory stock.group_stock_user 1 1 1 1
8 access_vertical_lift_command vertical_lift_command model_vertical_lift_command base.group_user 1 0 0 0

View File

@@ -71,6 +71,18 @@
<field name="use_tls"/>
</group>
</group>
<group groups="base.group_no_one">
<label for="command_ids"/>
<field name="command_ids">
<tree>
<field name="name"/>
<field name="command"/>
<field name="answer"/>
<field name="error"/>
<field name="create_date"/>
</tree>
</field>
</group>
</form>
</field>
</record>

View File

@@ -11,7 +11,8 @@
'stock_vertical_lift',
],
'website': 'https://www.camptocamp.com',
'data': [],
'data': [
],
'installable': True,
'development_status': 'Alpha',
}

View File

@@ -1,2 +1,3 @@
from . import stock_location
from . import vertical_lift_shuttle

View File

@@ -28,10 +28,10 @@ class StockLocation(models.Model):
x, y = '', ''
subst = {
'code': code,
'hostId': 'odoo',
'hostId': self.env['ir.sequence'].next_by_code('vertical.lift.command'),
'addr': shuttle.name,
'carrier': self.name,
'carrierNext': '',
'carrier': self.level,
'carrierNext': '0',
'x': x,
'y': y,
'boxType': '',
@@ -40,7 +40,7 @@ class StockLocation(models.Model):
'part': '',
'desc': '',
}
payload = message_template.format(subst)
payload = message_template.format(**subst)
return payload.encode('iso-8859-1', 'replace')
def _hardware_vertical_lift_tray_payload(self, cell_location=None):
@@ -83,4 +83,6 @@ class StockLocation(models.Model):
payload = self._hardware_kardex_prepare_payload()
_logger.debug("Sending to kardex: {}", payload)
# TODO implement the communication with kardex
super()._hardware_vertical_lift_tray_payload(cell_location=cell_location)
else:
payload = super()._hardware_vertical_lift_tray_payload(cell_location=cell_location)
return payload

View File

@@ -4,6 +4,24 @@
from odoo import api, models
JMIF_STATUS = {
0: 'success',
101: 'common error',
102: 'sequence number invalid',
103: 'machine busy',
104: 'timeout',
105: 'max retry reached',
106: 'carrier in use or undefined',
107: 'cancelled',
108: 'invalid user input data',
201: 'request accepted and queued',
202: 'request processing started / request active',
203: 'carrier arrived, maybe overwritten by code 0',
301: 'AO occupied with other try on move back (store / put)',
302: 'AO occupied with other try on fetch (pick)',
}
class VerticalLiftShuttle(models.Model):
_inherit = 'vertical.lift.shuttle'
@@ -13,19 +31,33 @@ class VerticalLiftShuttle(models.Model):
values += [('kardex', 'Kardex')]
return values
def _hardware_recv_response(self, conn):
# the implementation uses messages delimited with \r\n
response = b''
chunk = True
while chunk:
chunk = conn.recv(1)
response += chunk
if response.endswith(b'\r\n'):
break
return response
def _check_server_response(self, payload, response):
payload = payload.decode('iso-8859-1')
response = response.decode('iso-8859-1')
def _check_server_response(self, command):
response = command.answer
code, sep, remaining = response.partition('|')
return code == "0"
code = int(code)
if code == 0:
return True
elif 1 <= code <= 99:
command.error = 'interface error %d' % code
return False
elif code in JMIF_STATUS and code < 200:
command.error = '%d: %s' % (code, JMIF_STATUS[code])
return False
elif code in JMIF_STATUS and code < 300:
command.error = '%d: %s' % (code, JMIF_STATUS[code])
return True
elif code in JMIF_STATUS:
command.error = '%d: %s' % (code, JMIF_STATUS[code])
elif 501 <= code <= 999:
command.error = '%d: %s' % (code, 'MM260 Error')
elif 1000 <= code <= 32767:
command.error = '%d: %s' % (
code, 'C2000TCP/C3000CGI machine error'
)
elif 0xFF0 <= code == 0xFFF:
command.error = '%x: %s' % (
code, 'C3000CGI machine error (global short)'
)
elif 0xFFF < code:
command.error = '%x: %s' % (code, 'C3000CGI machine error (long)')
return False

View File

@@ -0,0 +1,175 @@
#!/usr/bin/python3
import argparse
import asyncio
import logging
import os
import ssl
import time
import aiohttp
_logger = logging.getLogger(__name__)
class KardexProxyProtocol(asyncio.Protocol):
def __init__(self, loop, queue, args):
_logger.info("Proxy created")
self.transport = None
self.buffer = b""
self.queue = queue
self.loop = loop
self.args = args
def connection_made(self, transport):
_logger.info("Proxy incoming cnx")
self.transport = transport
self.buffer = b""
def data_received(self, data):
self.buffer += data
_logger.info("Proxy: received %s", data)
if len(self.buffer) > 65535:
# prevent buffer overflow
self.transport.close()
def eof_received(self):
_logger.info("Proxy: received EOF")
if self.buffer[-1] != b"\n":
# bad format -> close
self.transport.close()
data = (
self.buffer.replace(b"\r\n", b"\n")
.replace(b"\n", b"\r\n")
.decode("iso-8859-1", "replace")
)
self.loop.create_task(self.queue.put(data))
self.buffer = b""
def connection_lost(self, exc):
self.transport = None
self.buffer = b""
class KardexClientProtocol(asyncio.Protocol):
def __init__(self, loop, queue, args):
_logger.info("started kardex client")
self.loop = loop
self.queue = queue
self.args = args
self.transport = None
self.buffer = b""
def connection_made(self, transport):
self.transport = transport
_logger.info("connected to kardex server %r", transport)
async def keepalive(self):
while True:
t = int(time.time())
msg = "61|ping%d|SH1-1|0|0||||||||\r\n" % t
await self.send_message(msg)
await asyncio.sleep(20)
async def send_message(self, message):
_logger.info("SEND %r", message)
message = message.encode("iso-8859-1")
self.transport.write(message)
async def process_queue(self):
while True:
message = await self.queue.get()
await self.send_message(message)
def data_received(self, data):
data = data.replace(b"\0", b"")
_logger.info("RECV %s", data)
self.buffer += data
if b"\r\n" in self.buffer:
msg, sep, rem = self.buffer.partition(b"\r\n")
self.buffer = rem
msg = msg.decode('iso-8859-1', 'replace').strip()
if msg.startswith('0|ping'):
_logger.info('ping ok')
else:
_logger.info('notify odoo: %s', msg)
self.loop.create_task(self.notify_odoo(msg))
def connection_lost(self, exc):
self.loop.stop()
async def notify_odoo(self, msg):
url = self.args.odoo_url + "/vertical-lift"
async with aiohttp.ClientSession() as session:
params = {'answer': msg, 'secret': self.args.secret}
async with session.post(url, data=params) as resp:
resp_text = await resp.text()
_logger.info(
'Reponse from Odoo: %s %s', resp.status, resp_text
)
def main(args, ssl_context=None):
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
)
loop = asyncio.get_event_loop()
queue = asyncio.Queue(loop=loop)
# create the main server
coro = loop.create_server(
lambda: KardexProxyProtocol(loop, queue, args),
host=args.host,
port=args.port
)
loop.run_until_complete(coro)
# create the connection to the JMIF client
if args.kardex_use_tls:
if ssl_context is None:
ssl_context = ssl.create_default_context()
else:
ssl_context = None
coro = loop.create_connection(
lambda: KardexClientProtocol(loop, queue, args),
host=args.kardex_host,
port=args.kardex_port,
ssl=ssl_context,
)
transport, client = loop.run_until_complete(coro)
loop.create_task(client.keepalive())
loop.create_task(client.process_queue())
loop.run_forever()
loop.close()
def make_parser():
listen_address = os.environ.get("INTERFACE", "0.0.0.0")
listen_port = int(os.environ.get("PORT", "7654"))
secret = os.environ.get("ODOO_CALLBACK_SECRET", "")
odoo_url = os.environ.get("ODOO_URL", "http://localhost:8069")
odoo_db = os.environ.get("ODOO_DB", "odoodb")
kardex_host = os.environ.get("KARDEX_HOST", "kardex")
kardex_port = int(os.environ.get("KARDEX_PORT", "9600"))
kardex_use_tls = (
False
if os.environ.get("KARDEX_TLS", "") in ("", "0", "False", "FALSE")
else True
)
parser = argparse.ArgumentParser()
arguments = [
("--host", listen_address, str),
("--port", listen_port, int),
("--odoo-url", odoo_url, str),
("--odoo-db", odoo_db, str),
("--secret", secret, str),
("--kardex-host", kardex_host, str),
("--kardex-port", kardex_port, str),
("--kardex-use-tls", kardex_use_tls, bool),
]
for name, default, type_ in arguments:
parser.add_argument(name, default=default, action="store", type=type_)
return parser
if __name__ == "__main__":
parser = make_parser()
args = parser.parse_args()
main(args)

View File

@@ -0,0 +1 @@
aiohttp

View File

@@ -0,0 +1,102 @@
import socket
import asyncio
import logging
import time
_logger = logging.getLogger('kardex.proxy')
logging.basicConfig(level=logging.DEBUG)
class KardexProxyProtocol(asyncio.Protocol):
def __init__(self, loop, queue):
_logger.info('Proxy created')
self.transport = None
self.buffer = b''
self.queue = queue
self.loop = loop
def connection_made(self, transport):
_logger.info('Proxy incoming cnx')
self.transport = transport
self.buffer = b''
def data_received(self, data):
self.buffer += data
_logger.info('Proxy: received %s', data)
if len(self.buffer) > 65535:
# prevent buffer overflow
self.transport.close()
def eof_received(self):
_logger.info('Proxy: received EOF')
if self.buffer[-1] != b'\n':
# bad format -> close
self.transport.close()
data = self.buffer.replace(b'\r\n', b'\n').replace(b'\n', b'\r\n').decode('iso-8859-1', 'replace')
task = self.loop.create_task(self.queue.put(data))
self.buffer = b''
print('toto', task)
def connection_lost(self, exc):
self.transport = None
self.buffer = b''
class KardexClientProtocol(asyncio.Protocol):
def __init__(self, loop, queue):
_logger.info('started kardex client')
self.loop = loop
self.queue = queue
self.transport = None
self.buffer = b''
def connection_made(self, transport):
self.transport = transport
_logger.info('connected to kardex server %r', transport)
async def keepalive(self):
while True:
t = int(time.time())
msg = '61|ping%d|SH1-1|0|0||||||||\r\n' % t
await self.send_message(msg)
await asyncio.sleep(5)
async def send_message(self, message):
_logger.info('SEND %s', message)
message = message.encode('iso-8859-1').ljust(1024, b'\0')
self.transport.write(message)
async def process_queue(self):
while True:
message = await self.queue.get()
await self.send_message(message)
def data_received(self, data):
data = data.replace(b'\0', b'')
_logger.info('RECV %s', data)
self.buffer += data
def connection_lost(self, exc):
self.loop.stop()
if __name__ == '__main__':
_logger.info('starting')
loop = asyncio.get_event_loop()
loop.set_debug(1)
queue = asyncio.Queue(loop=loop)
coro = loop.create_server(
lambda: KardexProxyProtocol(loop, queue),
port=3000,
family=socket.AF_INET
)
server = loop.run_until_complete(coro)
coro = loop.create_connection(lambda: KardexClientProtocol(loop, queue),
'localhost', 9600)
transport, client = loop.run_until_complete(coro)
print('%r' % transport)
loop.create_task(client.keepalive())
loop.create_task(client.process_queue())
_logger.info('run loop')
loop.run_forever()
loop.close()

View File

@@ -0,0 +1 @@
aiohttp