diff --git a/stock_vertical_lift/__init__.py b/stock_vertical_lift/__init__.py index 0650744f6..f7209b171 100644 --- a/stock_vertical_lift/__init__.py +++ b/stock_vertical_lift/__init__.py @@ -1 +1,2 @@ from . import models +from . import controllers diff --git a/stock_vertical_lift/__manifest__.py b/stock_vertical_lift/__manifest__.py index 11edb3846..04b5c5292 100644 --- a/stock_vertical_lift/__manifest__.py +++ b/stock_vertical_lift/__manifest__.py @@ -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', diff --git a/stock_vertical_lift/controllers/__init__.py b/stock_vertical_lift/controllers/__init__.py new file mode 100644 index 000000000..12a7e529b --- /dev/null +++ b/stock_vertical_lift/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/stock_vertical_lift/controllers/main.py b/stock_vertical_lift/controllers/main.py new file mode 100644 index 000000000..a03af9835 --- /dev/null +++ b/stock_vertical_lift/controllers/main.py @@ -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() diff --git a/stock_vertical_lift/data/ir_sequence.xml b/stock_vertical_lift/data/ir_sequence.xml new file mode 100644 index 000000000..9b231ed34 --- /dev/null +++ b/stock_vertical_lift/data/ir_sequence.xml @@ -0,0 +1,12 @@ + + + + + Vertical Lift Commands + vertical.lift.command + L + 6 + + + + diff --git a/stock_vertical_lift/models/__init__.py b/stock_vertical_lift/models/__init__.py index 87a31d40b..1db85b28e 100644 --- a/stock_vertical_lift/models/__init__.py +++ b/stock_vertical_lift/models/__init__.py @@ -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 diff --git a/stock_vertical_lift/models/stock_location.py b/stock_vertical_lift/models/stock_location.py index 14c1977ce..052bb6456 100644 --- a/stock_vertical_lift/models/stock_location.py +++ b/stock_vertical_lift/models/stock_location.py @@ -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 diff --git a/stock_vertical_lift/models/vertical_lift_command.py b/stock_vertical_lift/models/vertical_lift_command.py new file mode 100644 index 000000000..7c25b7f1f --- /dev/null +++ b/stock_vertical_lift/models/vertical_lift_command.py @@ -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) diff --git a/stock_vertical_lift/models/vertical_lift_shuttle.py b/stock_vertical_lift/models/vertical_lift_shuttle.py index 013586eed..d8b584fe1 100644 --- a/stock_vertical_lift/models/vertical_lift_shuttle.py +++ b/stock_vertical_lift/models/vertical_lift_shuttle.py @@ -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): diff --git a/stock_vertical_lift/security/ir.model.access.csv b/stock_vertical_lift/security/ir.model.access.csv index 63741764a..180158ce4 100644 --- a/stock_vertical_lift/security/ir.model.access.csv +++ b/stock_vertical_lift/security/ir.model.access.csv @@ -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 diff --git a/stock_vertical_lift/views/vertical_lift_shuttle_views.xml b/stock_vertical_lift/views/vertical_lift_shuttle_views.xml index 0a33ad98d..2f81f5d30 100644 --- a/stock_vertical_lift/views/vertical_lift_shuttle_views.xml +++ b/stock_vertical_lift/views/vertical_lift_shuttle_views.xml @@ -71,6 +71,18 @@ + + diff --git a/stock_vertical_lift_kardex/__manifest__.py b/stock_vertical_lift_kardex/__manifest__.py index 35670055d..8ccd05222 100644 --- a/stock_vertical_lift_kardex/__manifest__.py +++ b/stock_vertical_lift_kardex/__manifest__.py @@ -11,7 +11,8 @@ 'stock_vertical_lift', ], 'website': 'https://www.camptocamp.com', - 'data': [], + 'data': [ + ], 'installable': True, 'development_status': 'Alpha', } diff --git a/stock_vertical_lift_kardex/models/__init__.py b/stock_vertical_lift_kardex/models/__init__.py index 51a3830f6..5a191bac8 100644 --- a/stock_vertical_lift_kardex/models/__init__.py +++ b/stock_vertical_lift_kardex/models/__init__.py @@ -1,2 +1,3 @@ from . import stock_location from . import vertical_lift_shuttle + diff --git a/stock_vertical_lift_kardex/models/stock_location.py b/stock_vertical_lift_kardex/models/stock_location.py index e307aa21c..39d66fc28 100644 --- a/stock_vertical_lift_kardex/models/stock_location.py +++ b/stock_vertical_lift_kardex/models/stock_location.py @@ -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 diff --git a/stock_vertical_lift_kardex/models/vertical_lift_shuttle.py b/stock_vertical_lift_kardex/models/vertical_lift_shuttle.py index 67dc1131d..256439d35 100644 --- a/stock_vertical_lift_kardex/models/vertical_lift_shuttle.py +++ b/stock_vertical_lift_kardex/models/vertical_lift_shuttle.py @@ -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 diff --git a/stock_vertical_lift_kardex/proxy/kardex-proxy.py b/stock_vertical_lift_kardex/proxy/kardex-proxy.py new file mode 100644 index 000000000..f568c5a91 --- /dev/null +++ b/stock_vertical_lift_kardex/proxy/kardex-proxy.py @@ -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) diff --git a/stock_vertical_lift_kardex/proxy/requirements.txt b/stock_vertical_lift_kardex/proxy/requirements.txt new file mode 100644 index 000000000..ee4ba4f3d --- /dev/null +++ b/stock_vertical_lift_kardex/proxy/requirements.txt @@ -0,0 +1 @@ +aiohttp diff --git a/stock_vertical_lift_kardex/proxy/test.py b/stock_vertical_lift_kardex/proxy/test.py new file mode 100644 index 000000000..5b6ad5188 --- /dev/null +++ b/stock_vertical_lift_kardex/proxy/test.py @@ -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() diff --git a/stock_vertical_lift_kardex/requirements.txt b/stock_vertical_lift_kardex/requirements.txt new file mode 100644 index 000000000..ee4ba4f3d --- /dev/null +++ b/stock_vertical_lift_kardex/requirements.txt @@ -0,0 +1 @@ +aiohttp