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