commit a9d00500dafbd1cb68305522da6a49ca4c35fb33 Author: Ernad Husremovic Date: Fri Aug 29 15:20:52 2025 +0200 Initial commit: Hw packages diff --git a/README.md b/README.md new file mode 100644 index 0000000..64f7e78 --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# Hw + +This repository contains OCA OCB packages for hw. + +## Packages Included + +- odoo-bringout-oca-ocb-hw_drivers +- odoo-bringout-oca-ocb-hw_escpos +- odoo-bringout-oca-ocb-hw_posbox_homepage diff --git a/odoo-bringout-oca-ocb-hw_drivers/README.md b/odoo-bringout-oca-ocb-hw_drivers/README.md new file mode 100644 index 0000000..00b164e --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/README.md @@ -0,0 +1,53 @@ +# Hardware Proxy + + +Hardware Poxy +============= + +This module allows you to remotely use peripherals connected to this server. + +This modules only contains the enabling framework. The actual devices drivers +are found in other modules that must be installed separately. + + + +## Installation + +```bash +pip install odoo-bringout-oca-ocb-hw_drivers +``` + +## Dependencies + +This addon depends on: + + +## Manifest Information + +- **Name**: Hardware Proxy +- **Version**: N/A +- **Category**: Hidden +- **License**: LGPL-3 +- **Installable**: False + +## Source + +Based on [OCA/OCB](https://github.com/OCA/OCB) branch 16.0, addon `hw_drivers`. + +## License + +This package maintains the original LGPL-3 license from the upstream Odoo project. + +## Documentation + +- Overview: doc/OVERVIEW.md +- Architecture: doc/ARCHITECTURE.md +- Models: doc/MODELS.md +- Controllers: doc/CONTROLLERS.md +- Wizards: doc/WIZARDS.md +- Install: doc/INSTALL.md +- Usage: doc/USAGE.md +- Configuration: doc/CONFIGURATION.md +- Dependencies: doc/DEPENDENCIES.md +- Troubleshooting: doc/TROUBLESHOOTING.md +- FAQ: doc/FAQ.md diff --git a/odoo-bringout-oca-ocb-hw_drivers/doc/ARCHITECTURE.md b/odoo-bringout-oca-ocb-hw_drivers/doc/ARCHITECTURE.md new file mode 100644 index 0000000..05fd9f4 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/doc/ARCHITECTURE.md @@ -0,0 +1,32 @@ +# Architecture + +```mermaid +flowchart TD + U[Users] -->|HTTP| V[Views and QWeb Templates] + V --> C[Controllers] + V --> W[Wizards – Transient Models] + C --> M[Models and ORM] + W --> M + M --> R[Reports] + DX[Data XML] --> M + S[Security – ACLs and Groups] -. enforces .-> M + + subgraph Hw_drivers Module - hw_drivers + direction LR + M:::layer + W:::layer + C:::layer + V:::layer + R:::layer + S:::layer + DX:::layer + end + + classDef layer fill:#eef8ff,stroke:#6ea8fe,stroke-width:1px +``` + +Notes +- Views include tree/form/kanban templates and report templates. +- Controllers provide website/portal routes when present. +- Wizards are UI flows implemented with `models.TransientModel`. +- Data XML loads data/demo records; Security defines groups and access. diff --git a/odoo-bringout-oca-ocb-hw_drivers/doc/CONFIGURATION.md b/odoo-bringout-oca-ocb-hw_drivers/doc/CONFIGURATION.md new file mode 100644 index 0000000..b7944b1 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/doc/CONFIGURATION.md @@ -0,0 +1,3 @@ +# Configuration + +Refer to Odoo settings for hw_drivers. Configure related models, access rights, and options as needed. diff --git a/odoo-bringout-oca-ocb-hw_drivers/doc/CONTROLLERS.md b/odoo-bringout-oca-ocb-hw_drivers/doc/CONTROLLERS.md new file mode 100644 index 0000000..ff097c0 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/doc/CONTROLLERS.md @@ -0,0 +1,17 @@ +# Controllers + +HTTP routes provided by this module. + +```mermaid +sequenceDiagram + participant U as User/Client + participant C as Module Controllers + participant O as ORM/Views + + U->>C: HTTP GET/POST (routes) + C->>O: ORM operations, render templates + O-->>U: HTML/JSON/PDF +``` + +Notes +- See files in controllers/ for route definitions. diff --git a/odoo-bringout-oca-ocb-hw_drivers/doc/DEPENDENCIES.md b/odoo-bringout-oca-ocb-hw_drivers/doc/DEPENDENCIES.md new file mode 100644 index 0000000..3b4b1b6 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/doc/DEPENDENCIES.md @@ -0,0 +1,3 @@ +# Dependencies + +No explicit module dependencies declared. diff --git a/odoo-bringout-oca-ocb-hw_drivers/doc/FAQ.md b/odoo-bringout-oca-ocb-hw_drivers/doc/FAQ.md new file mode 100644 index 0000000..eeb3467 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/doc/FAQ.md @@ -0,0 +1,4 @@ +# FAQ + +- Q: Which Odoo version? A: 16.0 (OCA/OCB packaged). +- Q: How to enable? A: Start server with --addon hw_drivers or install in UI. diff --git a/odoo-bringout-oca-ocb-hw_drivers/doc/INSTALL.md b/odoo-bringout-oca-ocb-hw_drivers/doc/INSTALL.md new file mode 100644 index 0000000..b1896d4 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/doc/INSTALL.md @@ -0,0 +1,7 @@ +# Install + +```bash +pip install odoo-bringout-oca-ocb-hw_drivers" +# or +uv pip install odoo-bringout-oca-ocb-hw_drivers" +``` diff --git a/odoo-bringout-oca-ocb-hw_drivers/doc/MODELS.md b/odoo-bringout-oca-ocb-hw_drivers/doc/MODELS.md new file mode 100644 index 0000000..0fc67f1 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/doc/MODELS.md @@ -0,0 +1,11 @@ +# Models + +Detected core models and extensions in hw_drivers. + +```mermaid +classDiagram +``` + +Notes +- Classes show model technical names; fields omitted for brevity. +- Items listed under _inherit are extensions of existing models. diff --git a/odoo-bringout-oca-ocb-hw_drivers/doc/OVERVIEW.md b/odoo-bringout-oca-ocb-hw_drivers/doc/OVERVIEW.md new file mode 100644 index 0000000..d3e636b --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/doc/OVERVIEW.md @@ -0,0 +1,6 @@ +# Overview + +Packaged Odoo addon: hw_drivers. Provides features documented in upstream Odoo 16 under this addon. + +- Source: OCA/OCB 16.0, addon hw_drivers +- License: LGPL-3 diff --git a/odoo-bringout-oca-ocb-hw_drivers/doc/REPORTS.md b/odoo-bringout-oca-ocb-hw_drivers/doc/REPORTS.md new file mode 100644 index 0000000..e0ea35f --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/doc/REPORTS.md @@ -0,0 +1,3 @@ +# Reports + +This module does not define custom reports. diff --git a/odoo-bringout-oca-ocb-hw_drivers/doc/SECURITY.md b/odoo-bringout-oca-ocb-hw_drivers/doc/SECURITY.md new file mode 100644 index 0000000..e07da9d --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/doc/SECURITY.md @@ -0,0 +1,8 @@ +# Security + +This module does not define custom security rules or access controls beyond Odoo defaults. + +Default Odoo security applies: +- Base user access through standard groups +- Model access inherited from dependencies +- No custom row-level security rules diff --git a/odoo-bringout-oca-ocb-hw_drivers/doc/TROUBLESHOOTING.md b/odoo-bringout-oca-ocb-hw_drivers/doc/TROUBLESHOOTING.md new file mode 100644 index 0000000..56853cb --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/doc/TROUBLESHOOTING.md @@ -0,0 +1,5 @@ +# Troubleshooting + +- Ensure Python and Odoo environment matches repo guidance. +- Check database connectivity and logs if startup fails. +- Validate that dependent addons listed in DEPENDENCIES.md are installed. diff --git a/odoo-bringout-oca-ocb-hw_drivers/doc/USAGE.md b/odoo-bringout-oca-ocb-hw_drivers/doc/USAGE.md new file mode 100644 index 0000000..76c87aa --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/doc/USAGE.md @@ -0,0 +1,7 @@ +# Usage + +Start Odoo including this addon (from repo root): + +```bash +python3 scripts/nix_odoo_web_server.py --db-name mydb --addon hw_drivers +``` diff --git a/odoo-bringout-oca-ocb-hw_drivers/doc/WIZARDS.md b/odoo-bringout-oca-ocb-hw_drivers/doc/WIZARDS.md new file mode 100644 index 0000000..48e790d --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/doc/WIZARDS.md @@ -0,0 +1,3 @@ +# Wizards + +This module does not include UI wizards. diff --git a/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/__init__.py b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/__init__.py new file mode 100644 index 0000000..0921e5e --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/__init__.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import server_logger +from . import connection_manager +from . import controllers +from . import driver +from . import event_manager +from . import exception_logger +from . import http +from . import interface +from . import main diff --git a/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/__manifest__.py b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/__manifest__.py new file mode 100644 index 0000000..3b8bb31 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/__manifest__.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Hardware Proxy', + 'category': 'Hidden', + 'sequence': 6, + 'summary': 'Connect the Web Client to Hardware Peripherals', + 'website': 'https://www.odoo.com/app/iot', + 'description': """ +Hardware Poxy +============= + +This module allows you to remotely use peripherals connected to this server. + +This modules only contains the enabling framework. The actual devices drivers +are found in other modules that must be installed separately. + +""", + 'installable': False, + 'license': 'LGPL-3', +} diff --git a/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/connection_manager.py b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/connection_manager.py new file mode 100644 index 0000000..9fef997 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/connection_manager.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import datetime, timedelta +import logging +import subprocess +import requests +from threading import Thread +import time +import urllib3 + +from odoo.modules.module import get_resource_path +from odoo.addons.hw_drivers.main import iot_devices, manager +from odoo.addons.hw_drivers.tools import helpers + +_logger = logging.getLogger(__name__) + +class ConnectionManager(Thread): + def __init__(self): + super(ConnectionManager, self).__init__() + self.pairing_code = False + self.pairing_uuid = False + + def run(self): + if not helpers.get_odoo_server_url() and not helpers.access_point(): + end_time = datetime.now() + timedelta(minutes=5) + while (datetime.now() < end_time): + self._connect_box() + time.sleep(10) + self.pairing_code = False + self.pairing_uuid = False + self._refresh_displays() + + def _connect_box(self): + data = { + 'jsonrpc': 2.0, + 'params': { + 'pairing_code': self.pairing_code, + 'pairing_uuid': self.pairing_uuid, + } + } + + try: + urllib3.disable_warnings() + req = requests.post('https://iot-proxy.odoo.com/odoo-enterprise/iot/connect-box', json=data, verify=False) + result = req.json().get('result', {}) + if all(key in result for key in ['pairing_code', 'pairing_uuid']): + self.pairing_code = result['pairing_code'] + self.pairing_uuid = result['pairing_uuid'] + elif all(key in result for key in ['url', 'token', 'db_uuid', 'enterprise_code']): + self._connect_to_server(result['url'], result['token'], result['db_uuid'], result['enterprise_code']) + except Exception: + _logger.exception('Could not reach iot-proxy.odoo.com') + + def _connect_to_server(self, url, token, db_uuid, enterprise_code): + # Save DB URL and token + helpers.save_conf_server(url, token, db_uuid, enterprise_code) + # Notify the DB, so that the kanban view already shows the IoT Box + manager.send_alldevices() + # Restart to checkout the git branch, get a certificate, load the IoT handlers... + helpers.odoo_restart(2) + + def _refresh_displays(self): + """Refresh all displays to hide the pairing code""" + for d in iot_devices: + if iot_devices[d].device_type == 'display': + iot_devices[d].action({ + 'action': 'display_refresh' + }) + +connection_manager = ConnectionManager() +connection_manager.daemon = True +connection_manager.start() diff --git a/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/controllers/__init__.py b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/controllers/__init__.py new file mode 100644 index 0000000..ad287d4 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/controllers/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import driver +from . import proxy diff --git a/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/controllers/driver.py b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/controllers/driver.py new file mode 100755 index 0000000..0670187 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/controllers/driver.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from base64 import b64decode +import json +import logging +import os +import subprocess +import time + +from odoo import http, tools +from odoo.modules.module import get_resource_path + +from odoo.addons.hw_drivers.event_manager import event_manager +from odoo.addons.hw_drivers.main import iot_devices, manager +from odoo.addons.hw_drivers.tools import helpers + +_logger = logging.getLogger(__name__) + + +class DriverController(http.Controller): + @http.route('/hw_drivers/action', type='json', auth='none', cors='*', csrf=False, save_session=False) + def action(self, session_id, device_identifier, data): + """ + This route is called when we want to make a action with device (take picture, printing,...) + We specify in data from which session_id that action is called + And call the action of specific device + """ + iot_device = iot_devices.get(device_identifier) + if iot_device: + iot_device.data['owner'] = session_id + data = json.loads(data) + + # Skip the request if it was already executed (duplicated action calls) + iot_idempotent_id = data.get("iot_idempotent_id") + if iot_idempotent_id: + idempotent_session = iot_device._check_idempotency(iot_idempotent_id, session_id) + if idempotent_session: + _logger.info("Ignored request from %s as iot_idempotent_id %s already received from session %s", + session_id, iot_idempotent_id, idempotent_session) + return False + iot_device.action(data) + return True + return False + + @http.route('/hw_drivers/check_certificate', type='http', auth='none', cors='*', csrf=False, save_session=False) + def check_certificate(self): + """ + This route is called when we want to check if certificate is up-to-date + Used in iot-box cron.daily, deprecated since image 24_08 but needed for compatibility with the image 24_01 + """ + helpers.get_certificate_status() + + @http.route('/hw_drivers/event', type='json', auth='none', cors='*', csrf=False, save_session=False) + def event(self, listener): + """ + listener is a dict in witch there are a sessions_id and a dict of device_identifier to listen + """ + req = event_manager.add_request(listener) + + # Search for previous events and remove events older than 5 seconds + oldest_time = time.time() - 5 + for event in list(event_manager.events): + if event['time'] < oldest_time: + del event_manager.events[0] + continue + if event['device_identifier'] in listener['devices'] and event['time'] > listener['last_event']: + event['session_id'] = req['session_id'] + return event + + # Wait for new event + if req['event'].wait(50): + req['event'].clear() + req['result']['session_id'] = req['session_id'] + return req['result'] + + @http.route('/hw_drivers/download_logs', type='http', auth='none', cors='*', csrf=False, save_session=False) + def download_logs(self): + """ + Downloads the log file + """ + if tools.config['logfile']: + res = http.send_file(tools.config['logfile'], mimetype="text/plain", as_attachment=True) + res.headers['Cache-Control'] = 'no-cache' + return res diff --git a/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/controllers/proxy.py b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/controllers/proxy.py new file mode 100644 index 0000000..b512477 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/controllers/proxy.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import http + +proxy_drivers = {} + +class ProxyController(http.Controller): + @http.route('/hw_proxy/hello', type='http', auth='none', cors='*') + def hello(self): + return "ping" + + @http.route('/hw_proxy/handshake', type='json', auth='none', cors='*') + def handshake(self): + return True + + @http.route('/hw_proxy/status_json', type='json', auth='none', cors='*') + def status_json(self): + statuses = {} + for driver in proxy_drivers: + statuses[driver] = proxy_drivers[driver].get_status() + return statuses diff --git a/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/driver.py b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/driver.py new file mode 100644 index 0000000..0a5b51c --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/driver.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from threading import Thread, Event + +from odoo.addons.hw_drivers.main import drivers, iot_devices +from odoo.tools.lru import LRU + + +class DriverMetaClass(type): + priority = -1 + + def __new__(cls, clsname, bases, attrs): + newclass = super(DriverMetaClass, cls).__new__(cls, clsname, bases, attrs) + newclass.priority += 1 + if clsname != 'Driver': + drivers.append(newclass) + return newclass + + +class Driver(Thread, metaclass=DriverMetaClass): + """ + Hook to register the driver into the drivers list + """ + connection_type = '' + + def __init__(self, identifier, device): + super(Driver, self).__init__() + self.dev = device + self.device_identifier = identifier + self.device_name = '' + self.device_connection = '' + self.device_type = '' + self.device_manufacturer = '' + self.data = {'value': ''} + self._actions = {} + self._stopped = Event() + + # Least Recently Used (LRU) Cache that will store the idempotent keys already seen. + self._iot_idempotent_ids_cache = LRU(500) + + @classmethod + def supported(cls, device): + """ + On specific driver override this method to check if device is supported or not + return True or False + """ + return False + + def action(self, data): + """Helper function that calls a specific action method on the device. + + :param data: the `_actions` key mapped to the action method we want to call + :type data: string + """ + self._actions[data.get('action', '')](data) + + def disconnect(self): + self._stopped.set() + del iot_devices[self.device_identifier] + + def _check_idempotency(self, iot_idempotent_id, session_id): + """ + Some IoT requests for the same action might be received several times. + To avoid duplicating the resulting actions, we check if the action was "recently" executed. + If this is the case, we will simply ignore the action + + :return: the `session_id` of the same `iot_idempotent_id` if any. False otherwise, + which means that it is the first time that the IoT box received the request with this ID + """ + cache = self._iot_idempotent_ids_cache + if iot_idempotent_id in cache: + return cache[iot_idempotent_id] + cache[iot_idempotent_id] = session_id + return False diff --git a/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/event_manager.py b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/event_manager.py new file mode 100644 index 0000000..1b18d08 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/event_manager.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import json +from threading import Event +import time + +from odoo.http import request + +class EventManager(object): + def __init__(self): + self.events = [] + self.sessions = {} + + def _delete_expired_sessions(self, max_time=70): + ''' + Clears sessions that are no longer called. + + :param max_time: time a session can stay unused before being deleted + ''' + now = time.time() + expired_sessions = [ + session + for session in self.sessions + if now - self.sessions[session]['time_request'] > max_time + ] + for session in expired_sessions: + del self.sessions[session] + + def add_request(self, listener): + self.session = { + 'session_id': listener['session_id'], + 'devices': listener['devices'], + 'event': Event(), + 'result': {}, + 'time_request': time.time(), + } + self._delete_expired_sessions() + self.sessions[listener['session_id']] = self.session + return self.sessions[listener['session_id']] + + def device_changed(self, device): + event = { + **device.data, + 'device_identifier': device.device_identifier, + 'time': time.time(), + 'request_data': json.loads(request.params['data']) if request and 'data' in request.params else None, + } + self.events.append(event) + for session in self.sessions: + if device.device_identifier in self.sessions[session]['devices'] and not self.sessions[session]['event'].is_set(): + self.sessions[session]['result'] = event + self.sessions[session]['event'].set() + + +event_manager = EventManager() diff --git a/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/exception_logger.py b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/exception_logger.py new file mode 100644 index 0000000..c66be94 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/exception_logger.py @@ -0,0 +1,34 @@ + +from io import StringIO + +import logging +import sys + +_logger = logging.getLogger(__name__) + + +class ExceptionLogger: + """ + Redirect any unhandled python exception to the logger to keep track of them in the log file. + """ + def __init__(self): + self._buffer = StringIO() + + def write(self, message): + self._buffer.write(message) + if message.endswith('\n'): + self._flush_buffer() + + def _flush_buffer(self): + self._buffer.seek(0) + _logger.error(self._buffer.getvalue().rstrip('\n')) + self._buffer = StringIO() # Reset the buffer + + def flush(self): + if self._buffer.tell() > 0: + self._flush_buffer() + + def close(self): + self.flush() + +sys.stderr = ExceptionLogger() diff --git a/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/http.py b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/http.py new file mode 100644 index 0000000..bb02b70 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/http.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import odoo + + +def db_list(force=False, host=None): + return [] + +odoo.http.db_list = db_list diff --git a/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/interface.py b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/interface.py new file mode 100644 index 0000000..e2b8355 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/interface.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging +from threading import Thread +import time + +from odoo.addons.hw_drivers.main import drivers, interfaces, iot_devices + +_logger = logging.getLogger(__name__) + + +class InterfaceMetaClass(type): + def __new__(cls, clsname, bases, attrs): + new_interface = super(InterfaceMetaClass, cls).__new__(cls, clsname, bases, attrs) + if clsname != 'Interface': + interfaces[clsname] = new_interface + return new_interface + + +class Interface(Thread, metaclass=InterfaceMetaClass): + _loop_delay = 3 # Delay (in seconds) between calls to get_devices or 0 if it should be called only once + _detected_devices = {} + connection_type = '' + + def __init__(self): + super(Interface, self).__init__() + self.drivers = sorted([d for d in drivers if d.connection_type == self.connection_type], key=lambda d: d.priority, reverse=True) + + def run(self): + while self.connection_type and self.drivers: + self.update_iot_devices(self.get_devices()) + if not self._loop_delay: + break + time.sleep(self._loop_delay) + + def update_iot_devices(self, devices={}): + added = devices.keys() - self._detected_devices + removed = self._detected_devices - devices.keys() + # keys() returns a dict_keys, and the values of that stay in sync with the + # original dictionary if it changes. This means that get_devices needs to return + # a newly created dictionary every time. If it doesn't do that and reuses the + # same dictionary, this logic won't detect any changes that are made. Could be + # avoided by converting the dict_keys into a regular dict. The current logic + # also can't detect if a device is replaced by a different one with the same + # key. Also, _detected_devices starts out as a class variable but gets turned + # into an instance variable here. It would be better if it was an instance + # variable from the start to avoid confusion. + self._detected_devices = devices.keys() + + for identifier in removed: + if identifier in iot_devices: + iot_devices[identifier].disconnect() + _logger.info('Device %s is now disconnected', identifier) + + for identifier in added: + for driver in self.drivers: + if driver.supported(devices[identifier]): + _logger.info('Device %s is now connected', identifier) + d = driver(identifier, devices[identifier]) + d.daemon = True + iot_devices[identifier] = d + # Start the thread after creating the iot_devices entry so the + # thread can assume the iot_devices entry will exist while it's + # running, at least until the `disconnect` above gets triggered + # when `removed` is not empty. + d.start() + break + + def get_devices(self): + raise NotImplementedError() diff --git a/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/drivers/DisplayDriver_L.py b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/drivers/DisplayDriver_L.py new file mode 100644 index 0000000..b696145 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/drivers/DisplayDriver_L.py @@ -0,0 +1,234 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import jinja2 +import json +import logging +import netifaces as ni +import os +import subprocess +import threading +import time + +import urllib3 + +from odoo import http +from odoo.addons.hw_drivers.connection_manager import connection_manager +from odoo.addons.hw_drivers.driver import Driver +from odoo.addons.hw_drivers.event_manager import event_manager +from odoo.addons.hw_drivers.main import iot_devices +from odoo.addons.hw_drivers.tools import helpers + +path = os.path.realpath(os.path.join(os.path.dirname(__file__), '../../views')) +loader = jinja2.FileSystemLoader(path) + +jinja_env = jinja2.Environment(loader=loader, autoescape=True) +jinja_env.filters["json"] = json.dumps + +pos_display_template = jinja_env.get_template('pos_display.html') + +_logger = logging.getLogger(__name__) + + +class DisplayDriver(Driver): + connection_type = 'display' + + def __init__(self, identifier, device): + super(DisplayDriver, self).__init__(identifier, device) + self.device_type = 'display' + self.device_connection = 'hdmi' + self.device_name = device['name'] + self.event_data = threading.Event() + self.owner = False + self.rendered_html = '' + if self.device_identifier != 'distant_display': + self._x_screen = device.get('x_screen', '0') + self.load_url() + + self._actions.update({ + 'update_url': self._action_update_url, + 'display_refresh': self._action_display_refresh, + 'take_control': self._action_take_control, + 'customer_facing_display': self._action_customer_facing_display, + 'get_owner': self._action_get_owner, + }) + + @classmethod + def supported(cls, device): + return True # All devices with connection_type == 'display' are supported + + @classmethod + def get_default_display(cls): + displays = list(filter(lambda d: iot_devices[d].device_type == 'display', iot_devices)) + return len(displays) and iot_devices[displays[0]] + + def run(self): + while self.device_identifier != 'distant_display' and not self._stopped.is_set(): + time.sleep(60) + if self.url != 'http://localhost:8069/point_of_sale/display/' + self.device_identifier: + # Refresh the page every minute + self.call_xdotools('F5') + + def update_url(self, url=None): + os.environ['DISPLAY'] = ":0." + self._x_screen + os.environ['XAUTHORITY'] = '/run/lightdm/pi/xauthority' + firefox_env = os.environ.copy() + firefox_env['HOME'] = '/tmp/' + self._x_screen + self.url = url or 'http://localhost:8069/point_of_sale/display/' + self.device_identifier + new_window = subprocess.call(['xdotool', 'search', '--onlyvisible', '--screen', self._x_screen, '--class', 'Firefox']) + subprocess.Popen(['firefox', self.url], env=firefox_env) + if new_window: + self.call_xdotools('F11') + + def load_url(self): + url = None + if helpers.get_odoo_server_url(): + # disable certifiacte verification + urllib3.disable_warnings() + http = urllib3.PoolManager(cert_reqs='CERT_NONE') + try: + response = http.request('GET', "%s/iot/box/%s/display_url" % (helpers.get_odoo_server_url(), helpers.get_mac_address())) + if response.status == 200: + data = json.loads(response.data.decode('utf8')) + url = data[self.device_identifier] + except json.decoder.JSONDecodeError: + url = response.data.decode('utf8') + except Exception: + pass + return self.update_url(url) + + def call_xdotools(self, keystroke): + os.environ['DISPLAY'] = ":0." + self._x_screen + os.environ['XAUTHORITY'] = "/run/lightdm/pi/xauthority" + try: + subprocess.call(['xdotool', 'search', '--sync', '--onlyvisible', '--screen', self._x_screen, '--class', 'Firefox', 'key', keystroke]) + return "xdotool succeeded in stroking " + keystroke + except: + return "xdotool threw an error, maybe it is not installed on the IoTBox" + + def update_customer_facing_display(self, origin, html=None): + if origin == self.owner: + self.rendered_html = html + self.event_data.set() + + def get_serialized_order(self): + # IMPLEMENTATION OF LONGPOLLING + # Times out 2 seconds before the JS request does + if self.event_data.wait(28): + self.event_data.clear() + return {'rendered_html': self.rendered_html} + return {'rendered_html': False} + + def take_control(self, new_owner, html=None): + # ALLOW A CASHIER TO TAKE CONTROL OVER THE POSBOX, IN CASE OF MULTIPLE CASHIER PER DISPLAY + self.owner = new_owner + self.rendered_html = html + self.data = { + 'value': '', + 'owner': self.owner, + } + event_manager.device_changed(self) + self.event_data.set() + + def _action_update_url(self, data): + if self.device_identifier != 'distant_display': + self.update_url(data.get('url')) + + def _action_display_refresh(self, data): + if self.device_identifier != 'distant_display': + self.call_xdotools('F5') + + def _action_take_control(self, data): + self.take_control(self.data.get('owner'), data.get('html')) + + def _action_customer_facing_display(self, data): + self.update_customer_facing_display(self.data.get('owner'), data.get('html')) + + def _action_get_owner(self, data): + self.data = { + 'value': '', + 'owner': self.owner, + } + event_manager.device_changed(self) + +class DisplayController(http.Controller): + + @http.route('/hw_proxy/display_refresh', type='json', auth='none', cors='*') + def display_refresh(self): + display = DisplayDriver.get_default_display() + if display and display.device_identifier != 'distant_display': + return display.call_xdotools('F5') + + @http.route('/hw_proxy/customer_facing_display', type='json', auth='none', cors='*') + def customer_facing_display(self, html=None): + display = DisplayDriver.get_default_display() + if display: + display.update_customer_facing_display(http.request.httprequest.remote_addr, html) + return {'status': 'updated'} + return {'status': 'failed'} + + @http.route('/hw_proxy/take_control', type='json', auth='none', cors='*') + def take_control(self, html=None): + display = DisplayDriver.get_default_display() + if display: + display.take_control(http.request.httprequest.remote_addr, html) + return { + 'status': 'success', + 'message': 'You now have access to the display', + } + + @http.route('/hw_proxy/test_ownership', type='json', auth='none', cors='*') + def test_ownership(self): + display = DisplayDriver.get_default_display() + if display and display.owner == http.request.httprequest.remote_addr: + return {'status': 'OWNER'} + return {'status': 'NOWNER'} + + @http.route(['/point_of_sale/get_serialized_order', '/point_of_sale/get_serialized_order/'], type='json', auth='none') + def get_serialized_order(self, display_identifier=None): + if display_identifier: + display = iot_devices.get(display_identifier) + else: + display = DisplayDriver.get_default_display() + + if display: + return display.get_serialized_order() + return { + 'rendered_html': False, + 'error': "No display found", + } + + @http.route(['/point_of_sale/display', '/point_of_sale/display/'], type='http', auth='none') + def display(self, display_identifier=None): + cust_js = None + interfaces = ni.interfaces() + + with open(os.path.join(os.path.dirname(__file__), "../../static/src/js/worker.js")) as js: + cust_js = js.read() + + display_ifaces = [] + for iface_id in interfaces: + if 'wlan' in iface_id or 'eth' in iface_id: + iface_obj = ni.ifaddresses(iface_id) + ifconfigs = iface_obj.get(ni.AF_INET, []) + essid = helpers.get_ssid() + for conf in ifconfigs: + if conf.get('addr'): + display_ifaces.append({ + 'iface_id': iface_id, + 'essid': essid, + 'addr': conf.get('addr'), + 'icon': 'sitemap' if 'eth' in iface_id else 'wifi', + }) + + if not display_identifier: + display_identifier = DisplayDriver.get_default_display().device_identifier + + return pos_display_template.render({ + 'title': "Odoo -- Point of Sale", + 'breadcrumb': 'POS Client display', + 'cust_js': cust_js, + 'display_ifaces': display_ifaces, + 'display_identifier': display_identifier, + 'pairing_code': connection_manager.pairing_code, + }) diff --git a/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/drivers/KeyboardUSBDriver_L.py b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/drivers/KeyboardUSBDriver_L.py new file mode 100644 index 0000000..2e72c9a --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/drivers/KeyboardUSBDriver_L.py @@ -0,0 +1,384 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import ctypes +import evdev +import json +import logging +from lxml import etree +import os +from pathlib import Path +from queue import Queue, Empty +import re +import subprocess +from threading import Lock +import time +import urllib3 +from usb import util + +from odoo import http, _ +from odoo.addons.hw_drivers.controllers.proxy import proxy_drivers +from odoo.addons.hw_drivers.driver import Driver +from odoo.addons.hw_drivers.event_manager import event_manager +from odoo.addons.hw_drivers.main import iot_devices +from odoo.addons.hw_drivers.tools import helpers + +_logger = logging.getLogger(__name__) +xlib = ctypes.cdll.LoadLibrary('libX11.so.6') + + +class KeyboardUSBDriver(Driver): + # The list of devices can be found in /proc/bus/input/devices + # or "ls -l /dev/input/by-path" + # Note: the user running "evdev" commands must be inside the "input" group + # Each device's input will correspond to a file in /dev/input/event* + # The exact file can be found by looking at the "Handlers" line in /proc/bus/input/devices + # Example: "H: Handlers=sysrq kbd leds event0" -> The file used is /dev/input/event0 + # If you read the file "/dev/input/event0" you will get the input from the device in real time + # One usb device can have multiple associated event files (like a foot pedal which has 3 event files) + + connection_type = 'usb' + keyboard_layout_groups = [] + available_layouts = [] + input_devices = [] + + def __init__(self, identifier, device): + if not hasattr(KeyboardUSBDriver, 'display'): + os.environ['XAUTHORITY'] = "/run/lightdm/pi/xauthority" + KeyboardUSBDriver.display = xlib.XOpenDisplay(bytes(":0.0", "utf-8")) + + super(KeyboardUSBDriver, self).__init__(identifier, device) + self.device_connection = 'direct' + self.device_name = self._set_name() + + self._actions.update({ + 'update_layout': self._update_layout, + 'update_is_scanner': self._save_is_scanner, + '': self._action_default, + }) + + # from https://github.com/xkbcommon/libxkbcommon/blob/master/test/evdev-scancodes.h + self._scancode_to_modifier = { + 42: 'left_shift', + 54: 'right_shift', + 58: 'caps_lock', + 69: 'num_lock', + 100: 'alt_gr', # right alt + } + self._tracked_modifiers = {modifier: False for modifier in self._scancode_to_modifier.values()} + + if not KeyboardUSBDriver.available_layouts: + KeyboardUSBDriver.load_layouts_list() + KeyboardUSBDriver.send_layouts_list() + + for evdev_device in [evdev.InputDevice(path) for path in evdev.list_devices()]: + if (device.idVendor == evdev_device.info.vendor) and (device.idProduct == evdev_device.info.product): + self.input_devices.append(evdev_device) + + self._set_device_type('scanner') if self._is_scanner() else self._set_device_type() + + @classmethod + def supported(cls, device): + for cfg in device: + for itf in cfg: + if itf.bInterfaceClass == 3 and itf.bInterfaceProtocol != 2: + device.interface_protocol = itf.bInterfaceProtocol + return True + return False + + @classmethod + def get_status(self): + """Allows `hw_proxy.Proxy` to retrieve the status of the scanners""" + status = 'connected' if any(iot_devices[d].device_type == "scanner" for d in iot_devices) else 'disconnected' + return {'status': status, 'messages': ''} + + @classmethod + def send_layouts_list(cls): + server = helpers.get_odoo_server_url() + if server: + urllib3.disable_warnings() + pm = urllib3.PoolManager(cert_reqs='CERT_NONE') + server = server + '/iot/keyboard_layouts' + try: + pm.request('POST', server, fields={'available_layouts': json.dumps(cls.available_layouts)}) + except Exception: + _logger.exception('Could not reach configured server to send available layouts') + + @classmethod + def load_layouts_list(cls): + tree = etree.parse("/usr/share/X11/xkb/rules/base.xml", etree.XMLParser(ns_clean=True, recover=True)) + layouts = tree.xpath("//layout") + for layout in layouts: + layout_name = layout.xpath("./configItem/name")[0].text + layout_description = layout.xpath("./configItem/description")[0].text + KeyboardUSBDriver.available_layouts.append({ + 'name': layout_description, + 'layout': layout_name, + }) + for variant in layout.xpath("./variantList/variant"): + variant_name = variant.xpath("./configItem/name")[0].text + variant_description = variant.xpath("./configItem/description")[0].text + KeyboardUSBDriver.available_layouts.append({ + 'name': variant_description, + 'layout': layout_name, + 'variant': variant_name, + }) + + def _set_name(self): + try: + manufacturer = util.get_string(self.dev, self.dev.iManufacturer) + product = util.get_string(self.dev, self.dev.iProduct) + if manufacturer and product: + return re.sub(r"[^\w \-+/*&]", '', "%s - %s" % (manufacturer, product)) + except ValueError as e: + _logger.warning(e) + return _('Unknown input device') + + def run(self): + try: + for device in self.input_devices: + for event in device.read_loop(): + if self._stopped.is_set(): + break + if event.type == evdev.ecodes.EV_KEY: + data = evdev.categorize(event) + + modifier_name = self._scancode_to_modifier.get(data.scancode) + if modifier_name: + if modifier_name in ('caps_lock', 'num_lock'): + if data.keystate == 1: + self._tracked_modifiers[modifier_name] = not self._tracked_modifiers[modifier_name] + else: + self._tracked_modifiers[modifier_name] = bool(data.keystate) # 1 for keydown, 0 for keyup + elif data.keystate == 1: + self.key_input(data.scancode) + + except Exception as err: + _logger.warning(err) + + def _change_keyboard_layout(self, new_layout): + """Change the layout of the current device to what is specified in + new_layout. + + Args: + new_layout (dict): A dict containing two keys: + - layout (str): The layout code + - variant (str): An optional key to represent the variant of the + selected layout + """ + if hasattr(self, 'keyboard_layout'): + KeyboardUSBDriver.keyboard_layout_groups.remove(self.keyboard_layout) + + if new_layout: + self.keyboard_layout = new_layout.get('layout') or 'us' + if new_layout.get('variant'): + self.keyboard_layout += "(%s)" % new_layout['variant'] + else: + self.keyboard_layout = 'us' + + KeyboardUSBDriver.keyboard_layout_groups.append(self.keyboard_layout) + subprocess.call(["setxkbmap", "-display", ":0.0", ",".join(KeyboardUSBDriver.keyboard_layout_groups)]) + + # Close then re-open display to refresh the mapping + xlib.XCloseDisplay(KeyboardUSBDriver.display) + KeyboardUSBDriver.display = xlib.XOpenDisplay(bytes(":0.0", "utf-8")) + + def save_layout(self, layout): + """Save the layout to a file on the box to read it when restarting it. + We need that in order to keep the selected layout after a reboot. + + Args: + new_layout (dict): A dict containing two keys: + - layout (str): The layout code + - variant (str): An optional key to represent the variant of the + selected layout + """ + file_path = helpers.path_file('odoo-keyboard-layouts.conf') + if file_path.exists(): + data = json.loads(file_path.read_text()) + else: + data = {} + data[self.device_identifier] = layout + helpers.write_file('odoo-keyboard-layouts.conf', json.dumps(data)) + + def load_layout(self): + """Read the layout from the saved filed and set it as current layout. + If no file or no layout is found we use 'us' by default. + """ + file_path = helpers.path_file('odoo-keyboard-layouts.conf') + if file_path.exists(): + data = json.loads(file_path.read_text()) + layout = data.get(self.device_identifier, {'layout': 'us'}) + else: + layout = {'layout': 'us'} + self._change_keyboard_layout(layout) + + def _action_default(self, data): + self.data['value'] = '' + event_manager.device_changed(self) + + def _is_scanner(self): + """Read the device type from the saved filed and set it as current type. + If no file or no device type is found we try to detect it automatically. + """ + device_name = self.device_name.lower() + scanner_name = ['barcode', 'scanner', 'reader'] + is_scanner = any(x in device_name for x in scanner_name) or self.dev.interface_protocol == '0' + + file_path = helpers.path_file('odoo-keyboard-is-scanner.conf') + if file_path.exists(): + data = json.loads(file_path.read_text()) + is_scanner = data.get(self.device_identifier, {}).get('is_scanner', is_scanner) + return is_scanner + + def _keyboard_input(self, scancode): + """Deal with a keyboard input. Send the character corresponding to the + pressed key represented by its scancode to the connected Odoo instance. + + Args: + scancode (int): The scancode of the pressed key. + """ + self.data['value'] = self._scancode_to_char(scancode) + if self.data['value']: + event_manager.device_changed(self) + + def _barcode_scanner_input(self, scancode): + """Deal with a barcode scanner input. Add the new character scanned to + the current barcode or complete the barcode if "Return" is pressed. + When a barcode is completed, two tasks are performed: + - Send a device_changed update to the event manager to notify the + listeners that the value has changed (used in Enterprise). + - Add the barcode to the list barcodes that are being queried in + Community. + + Args: + scancode (int): The scancode of the pressed key. + """ + if scancode == 28: # Return + self.data['value'] = self._current_barcode + event_manager.device_changed(self) + self._barcodes.put((time.time(), self._current_barcode)) + self._current_barcode = '' + else: + self._current_barcode += self._scancode_to_char(scancode) + + def _save_is_scanner(self, data): + """Save the type of device. + We need that in order to keep the selected type of device after a reboot. + """ + is_scanner = {'is_scanner': data.get('is_scanner')} + file_path = helpers.path_file('odoo-keyboard-is-scanner.conf') + if file_path.exists(): + data = json.loads(file_path.read_text()) + else: + data = {} + data[self.device_identifier] = is_scanner + helpers.write_file('odoo-keyboard-is-scanner.conf', json.dumps(data)) + self._set_device_type('scanner') if is_scanner.get('is_scanner') else self._set_device_type() + + def _update_layout(self, data): + layout = { + 'layout': data.get('layout'), + 'variant': data.get('variant'), + } + self._change_keyboard_layout(layout) + self.save_layout(layout) + + def _set_device_type(self, device_type='keyboard'): + """Modify the device type between 'keyboard' and 'scanner' + + Args: + type (string): Type wanted to switch + """ + if device_type == 'scanner': + self.device_type = 'scanner' + self.key_input = self._barcode_scanner_input + self._barcodes = Queue() + self._current_barcode = '' + for device in self.input_devices: + device.grab() + self.read_barcode_lock = Lock() + else: + self.device_type = 'keyboard' + self.key_input = self._keyboard_input + self.load_layout() + + def _scancode_to_char(self, scancode): + """Translate a received scancode to a character depending on the + selected keyboard layout and the current state of the keyboard's + modifiers. + + Args: + scancode (int): The scancode of the pressed key, to be translated to + a character + + Returns: + str: The translated scancode. + """ + # Scancode -> Keysym : Depends on the keyboard layout + group = KeyboardUSBDriver.keyboard_layout_groups.index(self.keyboard_layout) + modifiers = self._get_active_modifiers(scancode) + keysym = ctypes.c_int(xlib.XkbKeycodeToKeysym(KeyboardUSBDriver.display, scancode + 8, group, modifiers)) + + # Translate Keysym to a character + key_pressed = ctypes.create_string_buffer(5) + xlib.XkbTranslateKeySym(KeyboardUSBDriver.display, ctypes.byref(keysym), 0, ctypes.byref(key_pressed), 5, ctypes.byref(ctypes.c_int())) + if key_pressed.value: + return key_pressed.value.decode('utf-8') + return '' + + def _get_active_modifiers(self, scancode): + """Get the state of currently active modifiers. + + Args: + scancode (int): The scancode of the key being translated + + Returns: + int: The current state of the modifiers: + 0 -- Lowercase + 1 -- Highercase or (NumLock + key pressed on keypad) + 2 -- AltGr + 3 -- Highercase + AltGr + """ + modifiers = 0 + uppercase = (self._tracked_modifiers['right_shift'] or self._tracked_modifiers['left_shift']) ^ self._tracked_modifiers['caps_lock'] + if uppercase or (scancode in [71, 72, 73, 75, 76, 77, 79, 80, 81, 82, 83] and self._tracked_modifiers['num_lock']): + modifiers += 1 + + if self._tracked_modifiers['alt_gr']: + modifiers += 2 + + return modifiers + + def read_next_barcode(self): + """Get the value of the last barcode that was scanned but not sent yet + and not older than 5 seconds. This function is used in Community, when + we don't have access to the IoTLongpolling. + + Returns: + str: The next barcode to be read or an empty string. + """ + + # Previous query still running, stop it by sending a fake barcode + if self.read_barcode_lock.locked(): + self._barcodes.put((time.time(), "")) + + with self.read_barcode_lock: + try: + timestamp, barcode = self._barcodes.get(True, 55) + if timestamp > time.time() - 5: + return barcode + except Empty: + return '' + +proxy_drivers['scanner'] = KeyboardUSBDriver + + +class KeyboardUSBController(http.Controller): + @http.route('/hw_proxy/scanner', type='json', auth='none', cors='*') + def get_barcode(self): + scanners = [iot_devices[d] for d in iot_devices if iot_devices[d].device_type == "scanner"] + if scanners: + return scanners[0].read_next_barcode() + time.sleep(5) + return None diff --git a/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/drivers/L10nEGDrivers.py b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/drivers/L10nEGDrivers.py new file mode 100644 index 0000000..fb8d345 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/drivers/L10nEGDrivers.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import base64 +import logging +import platform +import json + +from passlib.context import CryptContext + +from odoo import http +from odoo.tools.config import config + +_logger = logging.getLogger(__name__) + +try: + import PyKCS11 +except ImportError: + PyKCS11 = None + _logger.error('Could not import library PyKCS11') + +crypt_context = CryptContext(schemes=['pbkdf2_sha512']) + + +class EtaUsbController(http.Controller): + + def _is_access_token_valid(self, access_token): + stored_hash = config.get('proxy_access_token') + if not stored_hash: + # empty password/hash => authentication forbidden + return False + return crypt_context.verify(access_token, stored_hash) + + @http.route('/hw_l10n_eg_eta/certificate', type='http', auth='none', cors='*', csrf=False, save_session=False, methods=['POST']) + def eta_certificate(self, pin, access_token): + """ + Gets the certificate from the token and returns it to the main odoo instance so that we can prepare the + cades-bes object on the main odoo instance rather than this middleware + @param pin: pin of the token + @param access_token: token shared with the main odoo instance + """ + if not PyKCS11: + return self._get_error_template('no_pykcs11') + if not self._is_access_token_valid(access_token): + return self._get_error_template('unauthorized') + session, error = self._get_session(pin) + if error: + return error + try: + cert = session.findObjects([(PyKCS11.CKA_CLASS, PyKCS11.CKO_CERTIFICATE)])[0] + cert_bytes = bytes(session.getAttributeValue(cert, [PyKCS11.CKA_VALUE])[0]) + payload = { + 'certificate': base64.b64encode(cert_bytes).decode() + } + return json.dumps(payload) + except Exception as ex: + _logger.exception('Error while getting ETA certificate') + return self._get_error_template(str(ex)) + finally: + session.logout() + session.closeSession() + + @http.route('/hw_l10n_eg_eta/sign', type='http', auth='none', cors='*', csrf=False, save_session=False, methods=['POST']) + def eta_sign(self, pin, access_token, invoices): + """ + Check if the access_token is valid and sign the invoices accessing the usb key with the pin. + @param pin: pin of the token + @param access_token: token shared with the main odoo instance + @param invoices: dictionary of invoices. Keys are invoices ids, value are the base64 encoded binaries to sign + """ + if not PyKCS11: + return self._get_error_template('no_pykcs11') + if not self._is_access_token_valid(access_token): + return self._get_error_template('unauthorized') + session, error = self._get_session(pin) + if error: + return error + try: + cert = session.findObjects([(PyKCS11.CKA_CLASS, PyKCS11.CKO_CERTIFICATE)])[0] + cert_id = session.getAttributeValue(cert, [PyKCS11.CKA_ID])[0] + priv_key = session.findObjects([(PyKCS11.CKA_CLASS, PyKCS11.CKO_PRIVATE_KEY), (PyKCS11.CKA_ID, cert_id)])[0] + + invoice_dict = dict() + invoices = json.loads(invoices) + for invoice, eta_inv in invoices.items(): + to_sign = base64.b64decode(eta_inv) + signed_data = session.sign(priv_key, to_sign, PyKCS11.Mechanism(PyKCS11.CKM_SHA256_RSA_PKCS)) + invoice_dict[invoice] = base64.b64encode(bytes(signed_data)).decode() + + payload = { + 'invoices': json.dumps(invoice_dict), + } + return json.dumps(payload) + except Exception as ex: + _logger.exception('Error while signing invoices') + return self._get_error_template(str(ex)) + finally: + session.logout() + session.closeSession() + + def _get_session(self, pin): + session = False + + lib, error = self.get_crypto_lib() + if error: + return session, error + + try: + pkcs11 = PyKCS11.PyKCS11Lib() + pkcs11.load(pkcs11dll_filename=lib) + except PyKCS11.PyKCS11Error: + return session, self._get_error_template('missing_dll') + + slots = pkcs11.getSlotList(tokenPresent=True) + if not slots: + return session, self._get_error_template('no_drive') + if len(slots) > 1: + return session, self._get_error_template('multiple_drive') + + try: + session = pkcs11.openSession(slots[0], PyKCS11.CKF_SERIAL_SESSION | PyKCS11.CKF_RW_SESSION) + session.login(pin) + except Exception as ex: + error = self._get_error_template(str(ex)) + return session, error + + def get_crypto_lib(self): + error = lib = False + system = platform.system() + if system == 'Linux': + lib = '/usr/lib/x86_64-linux-gnu/opensc-pkcs11.so' + elif system == 'Windows': + lib = 'C:/Windows/System32/eps2003csp11.dll' + elif system == 'Darwin': + lib = '/Library/OpenSC/lib/onepin-opensc-pkcs11.so' + else: + error = self._get_error_template('unsupported_system') + return lib, error + + def _get_error_template(self, error_str): + return json.dumps({ + 'error': error_str, + }) diff --git a/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/drivers/L10nKeEDISerialDriver.py b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/drivers/L10nKeEDISerialDriver.py new file mode 100644 index 0000000..94da4f9 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/drivers/L10nKeEDISerialDriver.py @@ -0,0 +1,249 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging +import serial +import time +import struct +import json +from functools import reduce + +from odoo import http +from odoo.addons.hw_drivers.iot_handlers.drivers.SerialBaseDriver import SerialDriver, SerialProtocol, serial_connection +from odoo.addons.hw_drivers.main import iot_devices + +_logger = logging.getLogger(__name__) + +TremolG03Protocol = SerialProtocol( + name='Tremol G03', + baudrate=115200, + bytesize=serial.EIGHTBITS, + stopbits=serial.STOPBITS_ONE, + parity=serial.PARITY_NONE, + timeout=4, + writeTimeout=0.2, + measureRegexp=None, + statusRegexp=None, + commandTerminator=b'', + commandDelay=0.2, + measureDelay=3, + newMeasureDelay=0.2, + measureCommand=b'', + emptyAnswerValid=False, +) + +STX = 0x02 +ETX = 0x0A +ACK = 0x06 +NACK = 0x15 + +# Dictionary defining the output size of expected from various commands +COMMAND_OUTPUT_SIZE = { + 0x30: 7, + 0x31: 7, + 0x38: 157, + 0x39: 155, + 0x60: 40, + 0x68: 23, +} + +FD_ERRORS = { + 0x30: 'OK', + 0x32: 'Registers overflow', + 0x33: 'Clock failure or incorrect date & time', + 0x34: 'Opened fiscal receipt', + 0x39: 'Incorrect password', + 0x3b: '24 hours block - missing Z report', + 0x3d: 'Interrupt power supply in fiscal receipt (one time until status is read)', + 0x3e: 'Overflow EJ', + 0x3f: 'Insufficient conditions', +} + +COMMAND_ERRORS = { + 0x30: 'OK', + 0x31: 'Invalid command', + 0x32: 'Illegal command', + 0x33: 'Z daily report is not zero', + 0x34: 'Syntax error', + 0x35: 'Input registers orverflow', + 0x36: 'Zero input registers', + 0x37: 'Unavailable transaction for correction', + 0x38: 'Insufficient amount on hand', +} + + +class TremolG03Driver(SerialDriver): + """Driver for the Kenyan Tremol G03 fiscal device.""" + + _protocol = TremolG03Protocol + + def __init__(self, identifier, device): + super().__init__(identifier, device) + self.device_type = 'fiscal_data_module' + self.message_number = 0 + + @classmethod + def get_default_device(cls): + fiscal_devices = list(filter(lambda d: iot_devices[d].device_type == 'fiscal_data_module', iot_devices)) + return len(fiscal_devices) and iot_devices[fiscal_devices[0]] + + @classmethod + def supported(cls, device): + """Checks whether the device, which port info is passed as argument, is supported by the driver. + + :param device: path to the device + :type device: str + :return: whether the device is supported by the driver + :rtype: bool + """ + protocol = cls._protocol + try: + protocol = cls._protocol + with serial_connection(device['identifier'], protocol) as connection: + connection.write(b'\x09') + time.sleep(protocol.commandDelay) + response = connection.read(1) + if response == b'\x40': + return True + + except serial.serialutil.SerialTimeoutException: + pass + except Exception: + _logger.exception('Error while probing %s with protocol %s', device, protocol.name) + + # ---------------- + # HELPERS + # ---------------- + + @staticmethod + def generate_checksum(message): + """ Generate the checksum bytes for the bytes provided. + + :param message: bytes representing the part of the message from which the checksum is calculated + :returns: two checksum bytes calculated from the message + + This checksum is calculated as: + 1) XOR of all bytes of the bytes + 2) Conversion of the one XOR byte into the two bytes of the checksum by + adding 30h to each half-byte of the XOR + + eg. to_check = \x12\x23\x34\x45\x56 + XOR of all bytes in to_check = \x16 + checksum generated as \x16 -> \x31 \x36 + """ + xor = reduce(lambda a, b: a ^ b, message) + return bytes([(xor >> 4) + 0x30, (xor & 0xf) + 0x30]) + + # ---------------- + # COMMUNICATION + # ---------------- + + def send(self, msgs): + """ Send and receive messages to/from the fiscal device over serial connection + + Generate the wrapped message from the msgs and send them to the device. + The wrapping contains the (starting byte) (length byte) + and (message number byte) at the start and two (checksum + bytes), and the line-feed byte at the end. + :param msgs: A list of byte strings representing the and + components of the serial message. + :return: A list of the responses (if any) from the device. If the + response is an ack, it wont be part of this list. + """ + + with self._device_lock: + replies = [] + for msg in msgs: + self.message_number += 1 + core_message = struct.pack('BB%ds' % (len(msg)), len(msg) + 34, self.message_number + 32, msg) + request = struct.pack('B%ds2sB' % (len(core_message)), STX, core_message, self.generate_checksum(core_message), ETX) + time.sleep(self._protocol.commandDelay) + self._connection.write(request) + # If we know the expected output size, we can set the read + # buffer to match the size of the output. + output_size = COMMAND_OUTPUT_SIZE.get(msg[0]) + if output_size: + try: + response = self._connection.read(output_size) + except serial.serialutil.SerialTimeoutException: + _logger.exception('Timeout error while reading response to command %s', msg) + self.data['status'] = "Device timeout error" + else: + time.sleep(self._protocol.measureDelay) + response = self._connection.read_all() + if not response: + self.data['status'] = "No response" + _logger.error("Sent request: %s,\n Received no response", request) + self.abort_post() + break + if response[0] == ACK: + # In the case where either byte is not 0x30, there has been an error + if response[2] != 0x30 or response[3] != 0x30: + self.data['status'] = response[2:4].decode('cp1251') + _logger.error( + "Sent request: %s,\n Received fiscal device error: %s \n Received command error: %s", + request, FD_ERRORS.get(response[2], 'Unknown fiscal device error'), + COMMAND_ERRORS.get(response[3], 'Unknown command error'), + ) + self.abort_post() + break + replies.append('') + elif response[0] == NACK: + self.data['status'] = "Received NACK" + _logger.error("Sent request: %s,\n Received NACK \x15", request) + self.abort_post() + break + elif response[0] == 0x02: + self.data['status'] = "ok" + size = response[1] - 35 + reply = response[4:4 + size] + replies.append(reply.decode('cp1251')) + return {'replies': replies, 'status': self.data['status']} + + def abort_post(self): + """ Cancel the posting of the invoice + + In the event of an error, it is better to try to cancel the posting of + the invoice, since the state of the invoice on the device will remain + open otherwise, blocking further invoices being sent. + """ + self.message_number += 1 + abort = struct.pack('BBB', 35, self.message_number + 32, 0x39) + request = struct.pack('B3s2sB', STX, abort, self.generate_checksum(abort), ETX) + self._connection.write(request) + response = self._connection.read(COMMAND_OUTPUT_SIZE[0x39]) + if response and response[0] == 0x02: + self.data['status'] += "\n The invoice was successfully cancelled" + _logger.info("Invoice successfully cancelled") + else: + self.data['status'] += "\n The invoice could not be cancelled." + _logger.error("Failed to cancel invoice, received response: %s", response) + + +class TremolG03Controller(http.Controller): + + @http.route('/hw_proxy/l10n_ke_cu_send', type='http', auth='none', cors='*', csrf=False, save_session=False, methods=['POST']) + def l10n_ke_cu_send(self, messages, company_vat): + """ Posts the messages sent to this endpoint to the fiscal device connected to the server + + :param messages: The messages (consisting of and ) to + send to the fiscal device. + :returns: Dictionary containing a list of the responses from + fiscal device and status of the fiscal device. + """ + device = TremolG03Driver.get_default_device() + if device: + # First run the command to get the fiscal device numbers + device_numbers = device.send([b'\x60']) + # If the vat doesn't match, abort + if device_numbers['status'] != 'ok': + return device_numbers + serial_number, device_vat, _dummy = device_numbers['replies'][0].split(';') + if device_vat != company_vat: + return json.dumps({'status': 'The company vat number does not match that of the device'}) + messages = json.loads(messages) + device.message_number = 0 + resp = json.dumps({**device.send([msg.encode('cp1251') for msg in messages]), 'serial_number': serial_number}) + return resp + else: + return json.dumps({'status': 'The fiscal device is not connected to the proxy server'}) diff --git a/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/drivers/PrinterDriver_L.py b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/drivers/PrinterDriver_L.py new file mode 100644 index 0000000..25b2dfa --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/drivers/PrinterDriver_L.py @@ -0,0 +1,382 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from base64 import b64decode +from cups import IPPError, IPP_PRINTER_IDLE, IPP_PRINTER_PROCESSING, IPP_PRINTER_STOPPED +import dbus +import io +import logging +import netifaces as ni +import os +from PIL import Image, ImageOps +import re +import subprocess +import tempfile +from uuid import getnode as get_mac + +from odoo import http +from odoo.addons.hw_drivers.connection_manager import connection_manager +from odoo.addons.hw_drivers.controllers.proxy import proxy_drivers +from odoo.addons.hw_drivers.driver import Driver +from odoo.addons.hw_drivers.event_manager import event_manager +from odoo.addons.hw_drivers.iot_handlers.interfaces.PrinterInterface_L import PPDs, conn, cups_lock +from odoo.addons.hw_drivers.main import iot_devices +from odoo.addons.hw_drivers.tools import helpers + +_logger = logging.getLogger(__name__) + +RECEIPT_PRINTER_COMMANDS = { + 'star': { + 'center': b'\x1b\x1d\x61\x01', # ESC GS a n + 'cut': b'\x1b\x64\x02', # ESC d n + 'title': b'\x1b\x69\x01\x01%s\x1b\x69\x00\x00', # ESC i n1 n2 + 'drawers': [b'\x07', b'\x1a'] # BEL & SUB + }, + 'escpos': { + 'center': b'\x1b\x61\x01', # ESC a n + 'cut': b'\x1d\x56\x41\n', # GS V m + 'title': b'\x1b\x21\x30%s\x1b\x21\x00', # ESC ! n + 'drawers': [b'\x1b\x3d\x01', b'\x1b\x70\x00\x19\x19', b'\x1b\x70\x01\x19\x19'] # ESC = n then ESC p m t1 t2 + } +} + +def cups_notification_handler(message, uri, device_identifier, state, reason, accepting_jobs): + if device_identifier in iot_devices: + reason = reason if reason != 'none' else None + state_value = { + IPP_PRINTER_IDLE: 'connected', + IPP_PRINTER_PROCESSING: 'processing', + IPP_PRINTER_STOPPED: 'stopped' + } + iot_devices[device_identifier].update_status(state_value[state], message, reason) + +# Create a Cups subscription if it doesn't exist yet +try: + conn.getSubscriptions('/printers/') +except IPPError: + conn.createSubscription( + uri='/printers/', + recipient_uri='dbus://', + events=['printer-state-changed'] + ) + +# Listen for notifications from Cups +bus = dbus.SystemBus() +bus.add_signal_receiver(cups_notification_handler, signal_name="PrinterStateChanged", dbus_interface="org.cups.cupsd.Notifier") + + +class PrinterDriver(Driver): + connection_type = 'printer' + + def __init__(self, identifier, device): + super(PrinterDriver, self).__init__(identifier, device) + self.device_type = 'printer' + self.device_connection = device['device-class'].lower() + self.device_name = device['device-make-and-model'] + self.state = { + 'status': 'connecting', + 'message': 'Connecting to printer', + 'reason': None, + } + self.send_status() + + self._actions.update({ + 'cashbox': self.open_cashbox, + 'print_receipt': self.print_receipt, + '': self._action_default, + }) + + self.receipt_protocol = 'star' if 'STR_T' in device['device-id'] else 'escpos' + if 'direct' in self.device_connection and any(cmd in device['device-id'] for cmd in ['CMD:STAR;', 'CMD:ESC/POS;']): + self.print_status() + + @classmethod + def supported(cls, device): + if device.get('supported', False): + return True + protocol = ['dnssd', 'lpd', 'socket'] + if any(x in device['url'] for x in protocol) and device['device-make-and-model'] != 'Unknown' or 'direct' in device['device-class']: + model = cls.get_device_model(device) + ppdFile = '' + for ppd in PPDs: + if model and model in PPDs[ppd]['ppd-product']: + ppdFile = ppd + break + with cups_lock: + if ppdFile: + conn.addPrinter(name=device['identifier'], ppdname=ppdFile, device=device['url']) + else: + conn.addPrinter(name=device['identifier'], device=device['url']) + conn.setPrinterInfo(device['identifier'], device['device-make-and-model']) + conn.enablePrinter(device['identifier']) + conn.acceptJobs(device['identifier']) + conn.setPrinterUsersAllowed(device['identifier'], ['all']) + conn.addPrinterOptionDefault(device['identifier'], "usb-no-reattach", "true") + conn.addPrinterOptionDefault(device['identifier'], "usb-unidir", "true") + return True + return False + + @classmethod + def get_device_model(cls, device): + device_model = "" + if device.get('device-id'): + for device_id in [device_lo for device_lo in device['device-id'].split(';')]: + if any(x in device_id for x in ['MDL', 'MODEL']): + device_model = device_id.split(':')[1] + break + elif device.get('device-make-and-model'): + device_model = device['device-make-and-model'] + return re.sub(r"[\(].*?[\)]", "", device_model).strip() + + @classmethod + def get_status(cls): + status = 'connected' if any(iot_devices[d].device_type == "printer" and iot_devices[d].device_connection == 'direct' for d in iot_devices) else 'disconnected' + return {'status': status, 'messages': ''} + + def disconnect(self): + self.update_status('disconnected', 'Printer was disconnected') + super(PrinterDriver, self).disconnect() + + def update_status(self, status, message, reason=None): + """Updates the state of the current printer. + + Args: + status (str): The new value of the status + message (str): A comprehensive message describing the status + reason (str): The reason fo the current status + """ + if self.state['status'] != status or self.state['reason'] != reason: + self.state = { + 'status': status, + 'message': message, + 'reason': reason, + } + self.send_status() + + def send_status(self): + """ Sends the current status of the printer to the connected Odoo instance. + """ + self.data = { + 'value': '', + 'state': self.state, + } + event_manager.device_changed(self) + + def print_raw(self, data): + process = subprocess.Popen(["lp", "-d", self.device_identifier], stdin=subprocess.PIPE) + process.communicate(data) + if process.returncode != 0: + # The stderr isn't meaningful so we don't log it ('No such file or directory') + _logger.error('Printing failed: printer with the identifier "%s" could not be found', + self.device_identifier) + + def print_receipt(self, data): + receipt = b64decode(data['receipt']) + im = Image.open(io.BytesIO(receipt)) + + # Convert to greyscale then to black and white + im = im.convert("L") + im = ImageOps.invert(im) + im = im.convert("1") + + print_command = getattr(self, 'format_%s' % self.receipt_protocol)(im) + self.print_raw(print_command) + + def format_star(self, im): + width = int((im.width + 7) / 8) + + raster_init = b'\x1b\x2a\x72\x41' + raster_page_length = b'\x1b\x2a\x72\x50\x30\x00' + raster_send = b'\x62' + raster_close = b'\x1b\x2a\x72\x42' + + raster_data = b'' + dots = im.tobytes() + while len(dots): + raster_data += raster_send + width.to_bytes(2, 'little') + dots[:width] + dots = dots[width:] + + return raster_init + raster_page_length + raster_data + raster_close + + def format_escpos_bit_image_raster(self, im): + """ prints with the `GS v 0`-command """ + width = int((im.width + 7) / 8) + + raster_send = b'\x1d\x76\x30\x00' + max_slice_height = 255 + + raster_data = b'' + dots = im.tobytes() + while len(dots): + im_slice = dots[:width*max_slice_height] + slice_height = int(len(im_slice) / width) + raster_data += raster_send + width.to_bytes(2, 'little') + slice_height.to_bytes(2, 'little') + im_slice + dots = dots[width*max_slice_height:] + + return raster_data + + def extract_columns_from_picture(self, im, line_height): + # Code inspired from python esc pos library: + # https://github.com/python-escpos/python-escpos/blob/4a0f5855ef118a2009b843a3a106874701d8eddf/src/escpos/image.py#L73-L89 + width_pixels, height_pixels = im.size + for left in range(0, width_pixels, line_height): + box = (left, 0, left + line_height, height_pixels) + im_chunk = im.transform( + (line_height, height_pixels), + Image.EXTENT, + box + ) + yield im_chunk.tobytes() + + def format_escpos_bit_image_column(self, im, high_density_vertical=True, + high_density_horizontal=True, + size_scale=100): + """ prints with the `ESC *`-command + reference: https://reference.epson-biz.com/modules/ref_escpos/index.php?content_id=88 + + :param im: PIL image to print + :param high_density_vertical: print in high density in vertical direction + :param high_density_horizontal: print in high density in horizontal direction + :param size_scale: picture scale in percentage, + e.g: 50 -> half the size (horizontally and vertically) + """ + size_scale_ratio = size_scale / 100 + size_scale_width = int(im.width * size_scale_ratio) + size_scale_height = int(im.height * size_scale_ratio) + im = im.resize((size_scale_width, size_scale_height)) + # escpos ESC * command print column per column + # (instead of usual row by row). + # So we transpose the picture to ease the calculations + im = im.transpose(Image.ROTATE_270).transpose(Image.FLIP_LEFT_RIGHT) + + # Most of the code here is inspired from python escpos library + # https://github.com/python-escpos/python-escpos/blob/4a0f5855ef118a2009b843a3a106874701d8eddf/src/escpos/escpos.py#L237C9-L251 + ESC = b'\x1b' + density_byte = (1 if high_density_horizontal else 0) + \ + (32 if high_density_vertical else 0) + nL = im.height & 0xFF + nH = (im.height >> 8) & 0xFF + HEADER = ESC + b'*' + bytes([density_byte, nL, nH]) + + raster_data = ESC + b'3\x10' # Adjust line-feed size + line_height = 24 if high_density_vertical else 8 + for column in self.extract_columns_from_picture(im, line_height): + raster_data += HEADER + column + b'\n' + raster_data += ESC + b'2' # Reset line-feed size + return raster_data + + def format_escpos(self, im): + # Epson support different command to print pictures. + # We use by default "GS v 0", but it is incompatible with certain + # printer models (like TM-U2x0) + # As we are pretty limited in the information that we have, we will + # use the printer name to parse some configuration value + # Printer name examples: + # EpsonTMM30 + # -> Print using raster mode + # TM-U220__IMC_LDV_LDH_SCALE70__ + # -> Print using column bit image mode (without vertical and + # horizontal density and a scale of 70%) + + # Default image printing mode + image_mode = 'raster' + + options_str = self.device_name.split('__') + option_str = "" + if len(options_str) > 2: + option_str = options_str[1].upper() + if option_str.startswith('IMC'): + image_mode = 'column' + + if image_mode == 'column': + # Default printing mode parameters + high_density_vertical = True + high_density_horizontal = True + scale = 100 + + # Parse the printer name to get the needed parameters + # The separator need to not be filtered by `get_identifier` + options = option_str.split('_') + for option in options: + if option == 'LDV': + high_density_vertical = False + elif option == 'LDH': + high_density_horizontal = False + elif option.startswith('SCALE'): + scale_value_str = re.search(r'\d+$', option) + if scale_value_str is not None: + scale = int(scale_value_str.group()) + else: + raise ValueError( + "Missing printer SCALE parameter integer " + "value in option: " + option) + + res = self.format_escpos_bit_image_column(im, + high_density_vertical, + high_density_horizontal, + scale) + else: + res = self.format_escpos_bit_image_raster(im) + return res + RECEIPT_PRINTER_COMMANDS['escpos']['cut'] + + def print_status(self): + """Prints the status ticket of the IoTBox on the current printer.""" + wlan = '' + ip = '' + mac = '' + homepage = '' + pairing_code = '' + + ssid = helpers.get_ssid() + wlan = '\nWireless network:\n%s\n\n' % ssid + + interfaces = ni.interfaces() + ips = [] + for iface_id in interfaces: + iface_obj = ni.ifaddresses(iface_id) + ifconfigs = iface_obj.get(ni.AF_INET, []) + for conf in ifconfigs: + if conf.get('addr') and conf.get('addr'): + ips.append(conf.get('addr')) + if len(ips) == 0: + ip = '\nERROR: Could not connect to LAN\n\nPlease check that the IoTBox is correc-\ntly connected with a network cable,\n that the LAN is setup with DHCP, and\nthat network addresses are available' + elif len(ips) == 1: + ip = '\nIP Address:\n%s\n' % ips[0] + else: + ip = '\nIP Addresses:\n%s\n' % '\n'.join(ips) + + if len(ips) >= 1: + ips_filtered = [i for i in ips if i != '127.0.0.1'] + main_ips = ips_filtered and ips_filtered[0] or '127.0.0.1' + mac = '\nMAC Address:\n%s\n' % helpers.get_mac_address() + homepage = '\nHomepage:\nhttp://%s:8069\n\n' % main_ips + + code = connection_manager.pairing_code + if code: + pairing_code = '\nPairing Code:\n%s\n' % code + + commands = RECEIPT_PRINTER_COMMANDS[self.receipt_protocol] + title = commands['title'] % b'IoTBox Status' + self.print_raw(commands['center'] + title + b'\n' + wlan.encode() + mac.encode() + ip.encode() + homepage.encode() + pairing_code.encode() + commands['cut']) + + def open_cashbox(self, data): + """Sends a signal to the current printer to open the connected cashbox.""" + commands = RECEIPT_PRINTER_COMMANDS[self.receipt_protocol] + for drawer in commands['drawers']: + self.print_raw(drawer) + + def _action_default(self, data): + self.print_raw(b64decode(data['document'])) + + +class PrinterController(http.Controller): + + @http.route('/hw_proxy/default_printer_action', type='json', auth='none', cors='*') + def default_printer_action(self, data): + printer = next((d for d in iot_devices if iot_devices[d].device_type == 'printer' and iot_devices[d].device_connection == 'direct'), None) + if printer: + iot_devices[printer].action(data) + return True + return False + +proxy_drivers['printer'] = PrinterDriver diff --git a/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/drivers/PrinterDriver_W.py b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/drivers/PrinterDriver_W.py new file mode 100644 index 0000000..5ed4dbf --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/drivers/PrinterDriver_W.py @@ -0,0 +1,172 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from PIL import Image, ImageOps +import logging +from base64 import b64decode +import io +import win32print +import ghostscript + +from odoo.addons.hw_drivers.controllers.proxy import proxy_drivers +from odoo.addons.hw_drivers.driver import Driver +from odoo.addons.hw_drivers.event_manager import event_manager +from odoo.addons.hw_drivers.main import iot_devices +from odoo.addons.hw_drivers.tools import helpers +from odoo.tools.mimetypes import guess_mimetype + +_logger = logging.getLogger(__name__) + +RECEIPT_PRINTER_COMMANDS = { + 'star': { + 'center': b'\x1b\x1d\x61\x01', # ESC GS a n + 'cut': b'\x1b\x64\x02', # ESC d n + 'title': b'\x1b\x69\x01\x01%s\x1b\x69\x00\x00', # ESC i n1 n2 + 'drawers': [b'\x07', b'\x1a'] # BEL & SUB + }, + 'escpos': { + 'center': b'\x1b\x61\x01', # ESC a n + 'cut': b'\x1d\x56\x41\n', # GS V m + 'title': b'\x1b\x21\x30%s\x1b\x21\x00', # ESC ! n + 'drawers': [b'\x1b\x3d\x01', b'\x1b\x70\x00\x19\x19', b'\x1b\x70\x01\x19\x19'] # ESC = n then ESC p m t1 t2 + } +} + +class PrinterDriver(Driver): + connection_type = 'printer' + + def __init__(self, identifier, device): + super().__init__(identifier, device) + self.device_type = 'printer' + self.device_connection = 'network' + self.device_name = device.get('identifier') + self.printer_handle = device.get('printer_handle') + self.state = { + 'status': 'connecting', + 'message': 'Connecting to printer', + 'reason': None, + } + self.send_status() + + self._actions.update({ + 'cashbox': self.open_cashbox, + 'print_receipt': self.print_receipt, + '': self._action_default, + }) + + self.receipt_protocol = 'escpos' + + @classmethod + def supported(cls, device): + return True + + @classmethod + def get_status(cls): + status = 'connected' if any(iot_devices[d].device_type == "printer" and iot_devices[d].device_connection == 'direct' for d in iot_devices) else 'disconnected' + return {'status': status, 'messages': ''} + + def disconnect(self): + self.update_status('disconnected', 'Printer was disconnected') + super(PrinterDriver, self).disconnect() + + def update_status(self, status, message, reason=None): + """Updates the state of the current printer. + + Args: + status (str): The new value of the status + message (str): A comprehensive message describing the status + reason (str): The reason fo the current status + """ + if self.state['status'] != status or self.state['reason'] != reason: + self.state = { + 'status': status, + 'message': message, + 'reason': reason, + } + self.send_status() + + def send_status(self): + """ Sends the current status of the printer to the connected Odoo instance. + """ + self.data = { + 'value': '', + 'state': self.state, + } + event_manager.device_changed(self) + + def print_raw(self, data): + win32print.StartDocPrinter(self.printer_handle, 1, ('', None, "RAW")) + win32print.StartPagePrinter(self.printer_handle) + win32print.WritePrinter(self.printer_handle, data) + win32print.EndPagePrinter(self.printer_handle) + win32print.EndDocPrinter(self.printer_handle) + + def print_report(self, data): + helpers.write_file('document.pdf', data, 'wb') + file_name = helpers.path_file('document.pdf') + printer = self.device_name + + args = [ + "-dPrinted", "-dBATCH", "-dNOPAUSE", "-dNOPROMPT" + "-q", + "-sDEVICE#mswinpr2", + f'-sOutputFile#%printer%{printer}', + f'{file_name}' + ] + + _logger.debug("Printing report with ghostscript using %s", args) + stderr_buf = io.BytesIO() + stdout_buf = io.BytesIO() + stdout_log_level = logging.DEBUG + try: + ghostscript.Ghostscript(*args, stdout=stdout_buf, stderr=stderr_buf) + except Exception: + _logger.exception("Error while printing report, ghostscript args: %s, error buffer: %s", args, stderr_buf.getvalue()) + stdout_log_level = logging.ERROR # some stdout value might contains relevant error information + raise + finally: + _logger.log(stdout_log_level, "Ghostscript stdout: %s", stdout_buf.getvalue()) + + def print_receipt(self, data): + receipt = b64decode(data['receipt']) + im = Image.open(io.BytesIO(receipt)) + + # Convert to greyscale then to black and white + im = im.convert("L") + im = ImageOps.invert(im) + im = im.convert("1") + + print_command = getattr(self, 'format_%s' % self.receipt_protocol)(im) + self.print_raw(print_command) + + def format_escpos(self, im): + width = int((im.width + 7) / 8) + + raster_send = b'\x1d\x76\x30\x00' + max_slice_height = 255 + + raster_data = b'' + dots = im.tobytes() + while dots: + im_slice = dots[:width*max_slice_height] + slice_height = int(len(im_slice) / width) + raster_data += raster_send + width.to_bytes(2, 'little') + slice_height.to_bytes(2, 'little') + im_slice + dots = dots[width*max_slice_height:] + + return raster_data + RECEIPT_PRINTER_COMMANDS['escpos']['cut'] + + def open_cashbox(self, data): + """Sends a signal to the current printer to open the connected cashbox.""" + commands = RECEIPT_PRINTER_COMMANDS[self.receipt_protocol] + for drawer in commands['drawers']: + self.print_raw(drawer) + + def _action_default(self, data): + document = b64decode(data['document']) + mimetype = guess_mimetype(document) + if mimetype == 'application/pdf': + self.print_report(document) + else: + self.print_raw(document) + +proxy_drivers['printer'] = PrinterDriver diff --git a/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/drivers/SerialBaseDriver.py b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/drivers/SerialBaseDriver.py new file mode 100644 index 0000000..7bac3f5 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/drivers/SerialBaseDriver.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from collections import namedtuple +from contextlib import contextmanager +import logging +import serial +from threading import Lock +import time +import traceback + +from odoo import _ +from odoo.addons.hw_drivers.event_manager import event_manager +from odoo.addons.hw_drivers.driver import Driver + +_logger = logging.getLogger(__name__) + +SerialProtocol = namedtuple( + 'SerialProtocol', + "name baudrate bytesize stopbits parity timeout writeTimeout measureRegexp statusRegexp " + "commandTerminator commandDelay measureDelay newMeasureDelay " + "measureCommand emptyAnswerValid") + + +@contextmanager +def serial_connection(path, protocol, is_probing=False): + """Opens a serial connection to a device and closes it automatically after use. + + :param path: path to the device + :type path: string + :param protocol: an object containing the serial protocol to connect to a device + :type protocol: namedtuple + :param is_probing: a flag thet if set to `True` makes the timeouts longer, defaults to False + :type is_probing: bool, optional + """ + + PROBING_TIMEOUT = 1 + port_config = { + 'baudrate': protocol.baudrate, + 'bytesize': protocol.bytesize, + 'stopbits': protocol.stopbits, + 'parity': protocol.parity, + 'timeout': PROBING_TIMEOUT if is_probing else protocol.timeout, # longer timeouts for probing + 'writeTimeout': PROBING_TIMEOUT if is_probing else protocol.writeTimeout # longer timeouts for probing + } + connection = serial.Serial(path, **port_config) + yield connection + connection.close() + + +class SerialDriver(Driver): + """Abstract base class for serial drivers.""" + + _protocol = None + connection_type = 'serial' + + STATUS_CONNECTED = 'connected' + STATUS_ERROR = 'error' + STATUS_CONNECTING = 'connecting' + + def __init__(self, identifier, device): + """ Attributes initialization method for `SerialDriver`. + + :param device: path to the device + :type device: str + """ + + super(SerialDriver, self).__init__(identifier, device) + self._actions.update({ + 'get_status': self._push_status, + }) + self.device_connection = 'serial' + self._device_lock = Lock() + self._status = {'status': self.STATUS_CONNECTING, 'message_title': '', 'message_body': ''} + self._set_name() + + def _get_raw_response(connection): + pass + + def _push_status(self): + """Updates the current status and pushes it to the frontend.""" + + self.data['status'] = self._status + event_manager.device_changed(self) + + def _set_name(self): + """Tries to build the device's name based on its type and protocol name but falls back on a default name if that doesn't work.""" + + try: + name = ('%s serial %s' % (self._protocol.name, self.device_type)).title() + except Exception: + name = 'Unknown Serial Device' + self.device_name = name + + def _take_measure(self): + pass + + def _do_action(self, data): + """Helper function that calls a specific action method on the device. + + :param data: the `_actions` key mapped to the action method we want to call + :type data: string + """ + + with self._device_lock: + try: + self._actions[data['action']](data) + time.sleep(self._protocol.commandDelay) + except Exception: + msg = _(f'An error occurred while performing action "{data}" on "{self.device_name}"') + _logger.exception(msg) + self._status = {'status': self.STATUS_ERROR, 'message_title': msg, 'message_body': traceback.format_exc()} + self._push_status() + self._status = {'status': self.STATUS_CONNECTED, 'message_title': '', 'message_body': ''} + self.data['status'] = self._status + + def action(self, data): + """Establish a connection with the device if needed and have it perform a specific action. + + :param data: the `_actions` key mapped to the action method we want to call + :type data: string + """ + + if self._connection and self._connection.isOpen(): + self._do_action(data) + else: + with serial_connection(self.device_identifier, self._protocol) as connection: + self._connection = connection + self._do_action(data) + + def run(self): + """Continuously gets new measures from the device.""" + + try: + with serial_connection(self.device_identifier, self._protocol) as connection: + self._connection = connection + self._status['status'] = self.STATUS_CONNECTED + self._push_status() + while not self._stopped.is_set(): + self._take_measure() + time.sleep(self._protocol.newMeasureDelay) + except Exception: + msg = _('Error while reading %s', self.device_name) + _logger.exception(msg) + self._status = {'status': self.STATUS_ERROR, 'message_title': msg, 'message_body': traceback.format_exc()} + self._push_status() diff --git a/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/drivers/SerialScaleDriver.py b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/drivers/SerialScaleDriver.py new file mode 100644 index 0000000..52204ef --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/drivers/SerialScaleDriver.py @@ -0,0 +1,316 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from collections import namedtuple +import logging +import re +import serial +import threading +import time + +from odoo import http +from odoo.addons.hw_drivers.controllers.proxy import proxy_drivers +from odoo.addons.hw_drivers.event_manager import event_manager +from odoo.addons.hw_drivers.iot_handlers.drivers.SerialBaseDriver import SerialDriver, SerialProtocol, serial_connection + + +_logger = logging.getLogger(__name__) + +# Only needed to ensure compatibility with older versions of Odoo +ACTIVE_SCALE = None +new_weight_event = threading.Event() + +ScaleProtocol = namedtuple('ScaleProtocol', SerialProtocol._fields + ('zeroCommand', 'tareCommand', 'clearCommand', 'autoResetWeight')) + +# 8217 Mettler-Toledo (Weight-only) Protocol, as described in the scale's Service Manual. +# e.g. here: https://www.manualslib.com/manual/861274/Mettler-Toledo-Viva.html?page=51#manual +# Our recommended scale, the Mettler-Toledo "Ariva-S", supports this protocol on +# both the USB and RS232 ports, it can be configured in the setup menu as protocol option 3. +# We use the default serial protocol settings, the scale's settings can be configured in the +# scale's menu anyway. +Toledo8217Protocol = ScaleProtocol( + name='Toledo 8217', + baudrate=9600, + bytesize=serial.SEVENBITS, + stopbits=serial.STOPBITS_ONE, + parity=serial.PARITY_EVEN, + timeout=1, + writeTimeout=1, + measureRegexp=b"\x02\\s*([0-9.]+)N?\\r", + statusRegexp=b"\x02\\s*(\\?.)\\r", + commandDelay=0.2, + measureDelay=0.5, + newMeasureDelay=0.2, + commandTerminator=b'', + measureCommand=b'W', + zeroCommand=b'Z', + tareCommand=b'T', + clearCommand=b'C', + emptyAnswerValid=False, + autoResetWeight=False, +) + +# The ADAM scales have their own RS232 protocol, usually documented in the scale's manual +# e.g at https://www.adamequipment.com/media/docs/Print%20Publications/Manuals/PDF/AZEXTRA/AZEXTRA-UM.pdf +# https://www.manualslib.com/manual/879782/Adam-Equipment-Cbd-4.html?page=32#manual +# Only the baudrate and label format seem to be configurable in the AZExtra series. +ADAMEquipmentProtocol = ScaleProtocol( + name='Adam Equipment', + baudrate=4800, + bytesize=serial.EIGHTBITS, + stopbits=serial.STOPBITS_ONE, + parity=serial.PARITY_NONE, + timeout=0.2, + writeTimeout=0.2, + measureRegexp=br"\s*([0-9.]+)kg", # LABEL format 3 + KG in the scale settings, but Label 1/2 should work + statusRegexp=None, + commandTerminator=b"\r\n", + commandDelay=0.2, + measureDelay=0.5, + # AZExtra beeps every time you ask for a weight that was previously returned! + # Adding an extra delay gives the operator a chance to remove the products + # before the scale starts beeping. Could not find a way to disable the beeps. + newMeasureDelay=5, + measureCommand=b'P', + zeroCommand=b'Z', + tareCommand=b'T', + clearCommand=None, # No clear command -> Tare again + emptyAnswerValid=True, # AZExtra does not answer unless a new non-zero weight has been detected + autoResetWeight=True, # AZExtra will not return 0 after removing products +) + + +# Ensures compatibility with older versions of Odoo +class ScaleReadOldRoute(http.Controller): + @http.route('/hw_proxy/scale_read', type='json', auth='none', cors='*') + def scale_read(self): + if ACTIVE_SCALE: + return {'weight': ACTIVE_SCALE._scale_read_old_route()} + return None + + +class ScaleDriver(SerialDriver): + """Abstract base class for scale drivers.""" + last_sent_value = None + + def __init__(self, identifier, device): + super(ScaleDriver, self).__init__(identifier, device) + self.device_type = 'scale' + self._set_actions() + self._is_reading = True + + # Ensures compatibility with older versions of Odoo + # Only the last scale connected is kept + global ACTIVE_SCALE + ACTIVE_SCALE = self + proxy_drivers['scale'] = ACTIVE_SCALE + + # Ensures compatibility with older versions of Odoo + # and allows using the `ProxyDevice` in the point of sale to retrieve the status + def get_status(self): + """Allows `hw_proxy.Proxy` to retrieve the status of the scales""" + + status = self._status + return {'status': status['status'], 'messages': [status['message_title'], ]} + + def _set_actions(self): + """Initializes `self._actions`, a map of action keys sent by the frontend to backend action methods.""" + + self._actions.update({ + 'read_once': self._read_once_action, + 'set_zero': self._set_zero_action, + 'set_tare': self._set_tare_action, + 'clear_tare': self._clear_tare_action, + 'start_reading': self._start_reading_action, + 'stop_reading': self._stop_reading_action, + }) + + def _start_reading_action(self, data): + """Starts asking for the scale value.""" + self._is_reading = True + + def _stop_reading_action(self, data): + """Stops asking for the scale value.""" + self._is_reading = False + + def _clear_tare_action(self, data): + """Clears the scale current tare weight.""" + + # if the protocol has no clear tare command, we can just tare again + clearCommand = self._protocol.clearCommand or self._protocol.tareCommand + self._connection.write(clearCommand + self._protocol.commandTerminator) + + def _read_once_action(self, data): + """Reads the scale current weight value and pushes it to the frontend.""" + + self._read_weight() + self.last_sent_value = self.data['value'] + event_manager.device_changed(self) + + def _set_zero_action(self, data): + """Makes the weight currently applied to the scale the new zero.""" + + self._connection.write(self._protocol.zeroCommand + self._protocol.commandTerminator) + + def _set_tare_action(self, data): + """Sets the scale's current weight value as tare weight.""" + + self._connection.write(self._protocol.tareCommand + self._protocol.commandTerminator) + + @staticmethod + def _get_raw_response(connection): + """Gets raw bytes containing the updated value of the device. + + :param connection: a connection to the device's serial port + :type connection: pyserial.Serial + :return: the raw response to a weight request + :rtype: str + """ + + answer = [] + while True: + char = connection.read(1) + if not char: + break + else: + answer.append(bytes(char)) + return b''.join(answer) + + def _read_weight(self): + """Asks for a new weight from the scale, checks if it is valid and, if it is, makes it the current value.""" + + protocol = self._protocol + self._connection.write(protocol.measureCommand + protocol.commandTerminator) + answer = self._get_raw_response(self._connection) + match = re.search(self._protocol.measureRegexp, answer) + if match: + self.data = { + 'value': float(match.group(1)), + 'status': self._status + } + + # Ensures compatibility with older versions of Odoo + def _scale_read_old_route(self): + """Used when the iot app is not installed""" + with self._device_lock: + self._read_weight() + return self.data['value'] + + def _take_measure(self): + """Reads the device's weight value, and pushes that value to the frontend.""" + + with self._device_lock: + self._read_weight() + if self.data['value'] != self.last_sent_value or self._status['status'] == self.STATUS_ERROR: + self.last_sent_value = self.data['value'] + event_manager.device_changed(self) + + +class Toledo8217Driver(ScaleDriver): + """Driver for the Toldedo 8217 serial scale.""" + _protocol = Toledo8217Protocol + + def __init__(self, identifier, device): + super(Toledo8217Driver, self).__init__(identifier, device) + self.device_manufacturer = 'Toledo' + + @classmethod + def supported(cls, device): + """Checks whether the device, which port info is passed as argument, is supported by the driver. + + :param device: path to the device + :type device: str + :return: whether the device is supported by the driver + :rtype: bool + """ + + protocol = cls._protocol + + try: + with serial_connection(device['identifier'], protocol, is_probing=True) as connection: + connection.write(b'Ehello' + protocol.commandTerminator) + time.sleep(protocol.commandDelay) + answer = connection.read(8) + if answer == b'\x02E\rhello': + connection.write(b'F' + protocol.commandTerminator) + return True + except serial.serialutil.SerialTimeoutException: + pass + except Exception: + _logger.exception('Error while probing %s with protocol %s' % (device, protocol.name)) + return False + + +class AdamEquipmentDriver(ScaleDriver): + """Driver for the Adam Equipment serial scale.""" + + _protocol = ADAMEquipmentProtocol + priority = 0 # Test the supported method of this driver last, after all other serial drivers + + def __init__(self, identifier, device): + super(AdamEquipmentDriver, self).__init__(identifier, device) + self._is_reading = False + self._last_weight_time = 0 + self.device_manufacturer = 'Adam' + + def _check_last_weight_time(self): + """The ADAM doesn't make the difference between a value of 0 and "the same value as last time": + in both cases it returns an empty string. + With this, unless the weight changes, we give the user `TIME_WEIGHT_KEPT` seconds to log the new weight, + then change it back to zero to avoid keeping it indefinetely, which could cause issues. + In any case the ADAM must always go back to zero before it can weight again. + """ + + TIME_WEIGHT_KEPT = 10 + + if self.data['value'] is None: + if time.time() - self._last_weight_time > TIME_WEIGHT_KEPT: + self.data['value'] = 0 + else: + self._last_weight_time = time.time() + + def _take_measure(self): + """Reads the device's weight value, and pushes that value to the frontend.""" + + if self._is_reading: + with self._device_lock: + self._read_weight() + self._check_last_weight_time() + if self.data['value'] != self.last_sent_value or self._status['status'] == self.STATUS_ERROR: + self.last_sent_value = self.data['value'] + event_manager.device_changed(self) + else: + time.sleep(0.5) + + # Ensures compatibility with older versions of Odoo + def _scale_read_old_route(self): + """Used when the iot app is not installed""" + + time.sleep(3) + with self._device_lock: + self._read_weight() + self._check_last_weight_time() + return self.data['value'] + + @classmethod + def supported(cls, device): + """Checks whether the device at `device` is supported by the driver. + + :param device: path to the device + :type device: str + :return: whether the device is supported by the driver + :rtype: bool + """ + + protocol = cls._protocol + + try: + with serial_connection(device['identifier'], protocol, is_probing=True) as connection: + connection.write(protocol.measureCommand + protocol.commandTerminator) + # Checking whether writing to the serial port using the Adam protocol raises a timeout exception is about the only thing we can do. + return True + except serial.serialutil.SerialTimeoutException: + pass + except Exception: + _logger.exception('Error while probing %s with protocol %s' % (device, protocol.name)) + return False diff --git a/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/interfaces/DisplayInterface_L.py b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/interfaces/DisplayInterface_L.py new file mode 100644 index 0000000..fdb8324 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/interfaces/DisplayInterface_L.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from re import sub, finditer +import subprocess +import RPi.GPIO as GPIO +import logging + +from odoo.addons.hw_drivers.interface import Interface + + +_logger = logging.getLogger(__name__) + +try: + from vcgencmd import Vcgencmd +except ImportError: + Vcgencmd = None + _logger.warning('Could not import library vcgencmd') + + +class DisplayInterface(Interface): + _loop_delay = 0 + connection_type = 'display' + + def get_devices(self): + + # If no display connected, create "fake" device to be accessed from another computer + display_devices = { + 'distant_display' : { + 'name': "Distant Display", + }, + } + + if Vcgencmd: + return self.get_devices_vcgencmd() or display_devices + else: + return self.get_devices_tvservice() or display_devices + + + def get_devices_tvservice(self): + display_devices = {} + displays = subprocess.check_output(['tvservice', '-l']).decode() + x_screen = 0 + for match in finditer(r'Display Number (\d), type HDMI (\d)', displays): + display_id, hdmi_id = match.groups() + tvservice_output = subprocess.check_output(['tvservice', '-nv', display_id]).decode().strip() + if tvservice_output: + display_name = tvservice_output.split('=')[1] + display_identifier = sub('[^a-zA-Z0-9 ]+', '', display_name).replace(' ', '_') + "_" + str(hdmi_id) + iot_device = { + 'identifier': display_identifier, + 'name': display_name, + 'x_screen': str(x_screen), + } + display_devices[display_identifier] = iot_device + x_screen += 1 + + return display_devices + + def get_devices_vcgencmd(self): + """ + With the new IoT build 23_11 which uses Raspi OS Bookworm, + tvservice is no longer usable. + vcgencmd returns the display power state as on or off of the display whose ID is passed as the parameter. + The display ID for the preceding three methods are determined by the following table. + + Display ID + Main LCD 0 + Secondary LCD 1 + HDMI 0 2 + Composite 3 + HDMI 1 7 + """ + display_devices = {} + x_screen = 0 + hdmi_port = {'hdmi_0' : 2} # HDMI 0 + rpi_type = GPIO.RPI_INFO.get('TYPE') + # Check if it is a RPI 3B+ beacause he response on for booth hdmi port + if 'Pi 4' in rpi_type: + hdmi_port.update({'hdmi_1': 7}) # HDMI 1 + + try: + for hdmi in hdmi_port: + power_state_hdmi = Vcgencmd().display_power_state(hdmi_port.get(hdmi)) + if power_state_hdmi == 'on': + iot_device = { + 'identifier': hdmi, + 'name': 'Display hdmi ' + str(x_screen), + 'x_screen': str(x_screen), + } + display_devices[hdmi] = iot_device + x_screen += 1 + except subprocess.CalledProcessError: + _logger.warning('Vcgencmd "display_power_state" method call failed') + + return display_devices diff --git a/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/interfaces/PrinterInterface_L.py b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/interfaces/PrinterInterface_L.py new file mode 100644 index 0000000..448cd34 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/interfaces/PrinterInterface_L.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from cups import Connection as cups_connection +from re import sub +from threading import Lock + +from odoo.addons.hw_drivers.interface import Interface + +conn = cups_connection() +PPDs = conn.getPPDs() +cups_lock = Lock() # We can only make one call to Cups at a time + +class PrinterInterface(Interface): + _loop_delay = 120 + connection_type = 'printer' + printer_devices = {} + + def get_devices(self): + discovered_devices = {} + with cups_lock: + printers = conn.getPrinters() + devices = conn.getDevices() + for printer_name, printer in printers.items(): + path = printer.get('device-uri', False) + if printer_name != self.get_identifier(path): + printer.update({'supported': True}) # these printers are automatically supported + device_class = 'network' + if 'usb' in printer.get('device-uri'): + device_class = 'direct' + printer.update({'device-class': device_class}) + printer.update({'device-make-and-model': printer_name}) # give name setted in Cups + printer.update({'device-id': ''}) + devices.update({printer_name: printer}) + for path, device in devices.items(): + identifier = self.get_identifier(path) + device.update({'identifier': identifier}) + device.update({'url': path}) + device.update({'disconnect_counter': 0}) + discovered_devices.update({identifier: device}) + self.printer_devices.update(discovered_devices) + # Deal with devices which are on the list but were not found during this call of "get_devices" + # If they aren't detected 3 times consecutively, remove them from the list of available devices + for device in list(self.printer_devices): + if not discovered_devices.get(device): + disconnect_counter = self.printer_devices.get(device).get('disconnect_counter') + if disconnect_counter >= 2: + self.printer_devices.pop(device, None) + else: + self.printer_devices[device].update({'disconnect_counter': disconnect_counter + 1}) + return dict(self.printer_devices) + + def get_identifier(self, path): + """ + Necessary because the path is not always a valid Cups identifier, + as it may contain characters typically found in URLs or paths. + + - Removes characters: ':', '/', '.', '\', and space. + - Removes the exact strings: "uuid=" and "serial=". + + Example 1: + Input: "ipp://printers/printer1:1234/abcd" + Output: "ippprintersprinter11234abcd" + + Example 2: + Input: "uuid=1234-5678-90ab-cdef" + Output: "1234-5678-90ab-cdef + """ + return sub(r'[:\/\.\\ ]|(uuid=)|(serial=)', '', path) diff --git a/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/interfaces/PrinterInterface_W.py b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/interfaces/PrinterInterface_W.py new file mode 100644 index 0000000..c32c52d --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/interfaces/PrinterInterface_W.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import win32print + +from odoo.addons.hw_drivers.interface import Interface + +class PrinterInterface(Interface): + _loop_delay = 30 + connection_type = 'printer' + + def get_devices(self): + printer_devices = {} + printers = win32print.EnumPrinters(win32print.PRINTER_ENUM_LOCAL) + + for printer in printers: + identifier = printer[2] + handle_printer = win32print.OpenPrinter(identifier) + win32print.GetPrinter(handle_printer, 2) + printer_devices[identifier] = { + 'identifier': identifier, + 'printer_handle': handle_printer, + } + return printer_devices diff --git a/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/interfaces/SerialInterface.py b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/interfaces/SerialInterface.py new file mode 100644 index 0000000..d51e50f --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/interfaces/SerialInterface.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import serial.tools.list_ports + +from odoo.addons.hw_drivers.interface import Interface + + +class SerialInterface(Interface): + connection_type = 'serial' + + def get_devices(self): + serial_devices = {} + for port in serial.tools.list_ports.comports(): + serial_devices[port.device] = { + 'identifier': port.device + } + return serial_devices diff --git a/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/interfaces/USBInterface_L.py b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/interfaces/USBInterface_L.py new file mode 100644 index 0000000..a40e82d --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/iot_handlers/interfaces/USBInterface_L.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from usb import core + +from odoo.addons.hw_drivers.interface import Interface + + +class USBInterface(Interface): + connection_type = 'usb' + + def get_devices(self): + """ + USB devices are identified by a combination of their `idVendor` and + `idProduct`. We can't be sure this combination in unique per equipment. + To still allow connecting multiple similar equipments, we complete the + identifier by a counter. The drawbacks are we can't be sure the equipments + will get the same identifiers after a reboot or a disconnect/reconnect. + """ + usb_devices = {} + devs = core.find(find_all=True) + cpt = 2 + for dev in devs: + identifier = "usb_%04x:%04x" % (dev.idVendor, dev.idProduct) + if identifier in usb_devices: + identifier += '_%s' % cpt + cpt += 1 + usb_devices[identifier] = dev + return usb_devices diff --git a/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/main.py b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/main.py new file mode 100644 index 0000000..2100a0a --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/main.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from traceback import format_exc +import json +import platform +import logging +import socket +import subprocess +from threading import Thread +import time +import urllib3 + +from odoo.addons.hw_drivers.tools import helpers + +_logger = logging.getLogger(__name__) + +try: + import schedule +except ImportError: + schedule = None + _logger.warning('Could not import library schedule') + +try: + from dbus.mainloop.glib import DBusGMainLoop +except ImportError: + DBusGMainLoop = None + _logger.error('Could not import library dbus') + +drivers = [] +interfaces = {} +iot_devices = {} + + +class Manager(Thread): + def send_alldevices(self): + """ + This method send IoT Box and devices informations to Odoo database + """ + server = helpers.get_odoo_server_url() + if server: + subject = helpers.read_file_first_line('odoo-subject.conf') + if subject: + domain = helpers.get_ip().replace('.', '-') + subject.strip('*') + else: + domain = helpers.get_ip() + iot_box = { + 'name': socket.gethostname(), + 'identifier': helpers.get_mac_address(), + 'ip': domain, + 'token': helpers.get_token(), + 'version': helpers.get_version(detailed_version=True), + } + devices_list = {} + for device in iot_devices: + identifier = iot_devices[device].device_identifier + devices_list[identifier] = { + 'name': iot_devices[device].device_name, + 'type': iot_devices[device].device_type, + 'manufacturer': iot_devices[device].device_manufacturer, + 'connection': iot_devices[device].device_connection, + } + data = {'params': {'iot_box': iot_box, 'devices': devices_list,}} + # disable certifiacte verification + urllib3.disable_warnings() + http = urllib3.PoolManager(cert_reqs='CERT_NONE') + try: + http.request( + 'POST', + server + "/iot/setup", + body=json.dumps(data).encode('utf8'), + headers={ + 'Content-type': 'application/json', + 'Accept': 'text/plain', + }, + ) + except Exception: + _logger.exception('Could not reach configured server to send all IoT devices') + else: + _logger.warning('Odoo server not set') + + def run(self): + """ + Thread that will load interfaces and drivers and contact the odoo server with the updates + """ + + helpers.start_nginx_server() + if platform.system() == 'Linux' and not helpers.get_odoo_server_url(): + self.migrate_config() + + _logger.info("IoT Box Image version: %s", helpers.get_version(detailed_version=True)) + if platform.system() == 'Linux' and helpers.get_odoo_server_url(): + helpers.check_git_branch() + helpers.generate_password() + is_certificate_ok, certificate_details = helpers.get_certificate_status() + if not is_certificate_ok: + _logger.warning("An error happened when trying to get the HTTPS certificate: %s", + certificate_details) + + # We first add the IoT Box to the connected DB because IoT handlers cannot be downloaded if + # the identifier of the Box is not found in the DB. So add the Box to the DB. + self.send_alldevices() + helpers.download_iot_handlers() + helpers.load_iot_handlers() + + # Start the interfaces + for interface in interfaces.values(): + try: + i = interface() + i.daemon = True + i.start() + except Exception: + _logger.exception("Interface %s could not be started", str(interface)) + + # Set scheduled actions + schedule and schedule.every().day.at("00:00").do(helpers.get_certificate_status) + + # Check every 3 secondes if the list of connected devices has changed and send the updated + # list to the connected DB. + self.previous_iot_devices = [] + while 1: + try: + if iot_devices != self.previous_iot_devices: + self.previous_iot_devices = iot_devices.copy() + self.send_alldevices() + time.sleep(3) + schedule and schedule.run_pending() + except Exception: + # No matter what goes wrong, the Manager loop needs to keep running + _logger.exception("Manager loop unexpected error") + + def migrate_config(self): + """ + This is a workaround for new IoT box images (>=24.10) not working correctly after + checking out a v16 database. It transforms the new odoo.conf settings into their + equivalent config files. + """ + with helpers.writable(): + subprocess.run( + ['/home/pi/odoo/addons/point_of_sale/tools/posbox/configuration/migrate_config.sh'], check=True + ) + +# Must be started from main thread +if DBusGMainLoop: + DBusGMainLoop(set_as_default=True) + +manager = Manager() +manager.daemon = True +manager.start() diff --git a/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/server_logger.py b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/server_logger.py new file mode 100644 index 0000000..55e6086 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/server_logger.py @@ -0,0 +1,164 @@ + +import logging +import queue +import requests +import threading +import time +import urllib3.exceptions + +from odoo.addons.hw_drivers.tools import helpers +from odoo.netsvc import DBFormatter +from odoo.tools import config + +_logger = logging.getLogger(__name__) + +IOT_LOG_TO_SERVER_CONFIG_NAME = 'iot_log_to_server' # config name in odoo.conf + + +class AsyncHTTPHandler(logging.Handler): + """ + Custom logging handler which send IoT logs using asynchronous requests. + To avoid spamming the server, we send logs by batch each X seconds + """ + _MAX_QUEUE_SIZE = 1000 + """Maximum queue size. If a log record is received but the queue if full it will be discarded""" + _MAX_BATCH_SIZE = 50 + """Maximum number of sent logs batched at once. Used to avoid too heavy request. Log records still in the queue will + be handle in future flushes""" + _FLUSH_INTERVAL = 0.5 + """How much seconds it will sleep before checking for new logs to send""" + _REQUEST_TIMEOUT = 0.5 + """Amount of seconds to wait per log to send before timeout""" + _DELAY_BEFORE_NO_SERVER_LOG = 5 * 60 # 5 minutes + """Minimum delay in seconds before we log a server disconnection. + Used in order to avoid the IoT log file to have a log recorded each _FLUSH_INTERVAL (as this value is very small)""" + + def __init__(self, odoo_server_url, active): + """ + :param odoo_server_url: Odoo Server URL + """ + super().__init__() + self._odoo_server_url = odoo_server_url + self._log_queue = queue.Queue(self._MAX_QUEUE_SIZE) + self._flush_thread = None + self._active = None + self._next_disconnection_time = None + self.toggle_active(active) + + def toggle_active(self, is_active): + """ + Switch it on or off the handler (depending on the IoT setting) without the need to close/reset it + """ + self._active = is_active + if self._active and self._odoo_server_url: + # Start the thread to periodically flush logs + self._flush_thread = threading.Thread(target=self._periodic_flush, name="ThreadServerLogSender", daemon=True) + self._flush_thread.start() + else: + self._flush_thread and self._flush_thread.join() # let a last flush + + def _periodic_flush(self): + odoo_session = requests.Session() + while self._odoo_server_url and self._active: # allow to exit the loop on thread.join + time.sleep(self._FLUSH_INTERVAL) + self._flush_logs(odoo_session) + + def _flush_logs(self, odoo_session): + def convert_to_byte(s): + return bytes(s, encoding="utf-8") + b'\n' + + def convert_server_line(log_level, line_formatted): + return convert_to_byte(f"{log_level},{line_formatted}") + + def empty_queue(): + yield convert_to_byte(f"mac {helpers.get_mac_address()}") + for _ in range(self._MAX_BATCH_SIZE): + # Use a limit to avoid having too heavy requests & infinite loop of the queue receiving new entries + try: + log_record = self._log_queue.get_nowait() + yield convert_server_line(log_record.levelno, self.format(log_record)) + except queue.Empty: + break + + # Report to the server if the queue is close from saturation + if queue_size >= .8 * self._MAX_QUEUE_SIZE: + log_message = "The IoT {} queue is saturating: {}/{} ({:.2f}%)".format( + self.__class__.__name__, queue_size, self._MAX_QUEUE_SIZE, + 100 * queue_size / self._MAX_QUEUE_SIZE) + _logger.warning(log_message) # As we don't log our own logs, this will be part of the IoT logs + # In order to report this to the server (on the current batch) we will append it manually + yield convert_server_line(logging.WARNING, log_message) + + queue_size = self._log_queue.qsize() # This is an approximate value + + if not self._odoo_server_url or queue_size == 0: + return + try: + odoo_session.post( + self._odoo_server_url + '/iot/log', + data=empty_queue(), + timeout=self._REQUEST_TIMEOUT + ).raise_for_status() + self._next_disconnection_time = None + except (requests.exceptions.ConnectTimeout, requests.exceptions.ConnectionError, urllib3.exceptions.NewConnectionError) as request_errors: + now = time.time() + if not self._next_disconnection_time or now >= self._next_disconnection_time: + _logger.info("Connection with the server to send the logs failed. It is likely down: %s", request_errors) + self._next_disconnection_time = now + self._DELAY_BEFORE_NO_SERVER_LOG + except Exception as _: + _logger.exception('Unexpected error happened while sending logs to server') + + def emit(self, record): + # This is important that this method is as fast as possible. + # The log calls will be waiting for this function to finish + if not self._active: + return + try: + self._log_queue.put_nowait(record) + except queue.Full: + pass + + def close(self): + self.toggle_active(False) + super().close() + + +def close_server_log_sender_handler(): + _server_log_sender_handler.close() + + +def get_odoo_config_log_to_server_option(): + return config.get(IOT_LOG_TO_SERVER_CONFIG_NAME, True) # Enabled by default + + +def check_and_update_odoo_config_log_to_server_option(new_state): + """ + :return: wherever the config file need to be updated or not + """ + if get_odoo_config_log_to_server_option() != new_state: + config[IOT_LOG_TO_SERVER_CONFIG_NAME] = new_state + _server_log_sender_handler.toggle_active(new_state) + return True + return False + + +def _server_log_sender_handler_filter(log_record): + def _filter_my_logs(): + """Filter out our own logs (to avoid infinite loop)""" + return log_record.name == __name__ + + def _filter_frequent_irrelevant_calls(): + """Filter out this frequent irrelevant HTTP calls, to avoid spamming the server with useless logs""" + return log_record.name == 'werkzeug' and log_record.args and len(log_record.args) > 0 and log_record.args[0].startswith('GET /hw_proxy/hello ') + + return not (_filter_my_logs() or _filter_frequent_irrelevant_calls()) + + +# The server URL is set once at initlialisation as the IoT will always restart if the URL is changed +# The only other possible case is when the server URL value is "Cleared", +# in this case we force close the log handler (as it does not make sense anymore) +_server_log_sender_handler = AsyncHTTPHandler(helpers.get_odoo_server_url(), get_odoo_config_log_to_server_option()) +_server_log_sender_handler.setFormatter(DBFormatter('%(asctime)s %(pid)s %(levelname)s %(dbname)s %(name)s: %(message)s %(perf_info)s')) +_server_log_sender_handler.addFilter(_server_log_sender_handler_filter) +# Set it in the 'root' logger, on which every logger (including odoo) is a child +logging.getLogger().addHandler(_server_log_sender_handler) diff --git a/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/static/src/js/worker.js b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/static/src/js/worker.js new file mode 100644 index 0000000..a6d64de --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/static/src/js/worker.js @@ -0,0 +1,63 @@ +/* global display_identifier */ + $(function() { + "use strict"; + // mergedHead will be turned to true the first time we receive something from a new host + // It allows to transform the only once + var mergedHead = false; + var current_client_url = ""; + + function longpolling() { + $.ajax({ + type: 'POST', + url: window.location.origin + '/point_of_sale/get_serialized_order/' + display_identifier, + dataType: 'json', + beforeSend: function(xhr){xhr.setRequestHeader('Content-Type', 'application/json');}, + data: JSON.stringify({jsonrpc: '2.0'}), + + success: function(data) { + if (data.result.error) { + $('.error-message').text(data.result.error); + $('.error-message').removeClass('d-none'); + setTimeout(longpolling, 5000); + return; + } + if (data.result.rendered_html) { + var trimmed = $.trim(data.result.rendered_html); + var $parsedHTML = $('
').html($.parseHTML(trimmed,true)); // WARNING: the true here will executes any script present in the string to parse + var new_client_url = $parsedHTML.find(".resources > base").attr('href'); + + if (!mergedHead || (current_client_url !== new_client_url)) { + + mergedHead = true; + current_client_url = new_client_url; + $("head").children().not('.origin').remove(); + $("head").append($parsedHTML.find(".resources").html()); + } + + $(".container-fluid").html($parsedHTML.find('.pos-customer_facing_display').html()); + $(".container-fluid").attr('class', 'container-fluid').addClass($parsedHTML.find('.pos-customer_facing_display').attr('class')); + + var d = $('.pos_orderlines_list'); + d.scrollTop(d.prop("scrollHeight")); + + // Here we execute the code coming from the pos, apparently $.parseHTML() executes scripts right away, + // Since we modify the dom afterwards, the script might not have any effect + /* eslint-disable no-undef */ + if (typeof foreign_js !== 'undefined' && $.isFunction(foreign_js)) { + foreign_js(); + } + /* eslint-enable no-undef */ + } + longpolling(); + }, + + error: function (jqXHR, status, err) { + setTimeout(longpolling, 5000); + }, + + timeout: 30000, + }); + }; + + longpolling(); + }); diff --git a/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/tools/helpers.py b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/tools/helpers.py new file mode 100644 index 0000000..fab9941 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/tools/helpers.py @@ -0,0 +1,501 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import datetime +from enum import Enum +from importlib import util +import platform +import io +import json +import logging +import netifaces +from OpenSSL import crypto +import os +from pathlib import Path +import subprocess +import urllib3 +import zipfile +from threading import Thread +import time +import contextlib +import requests +import secrets + +from odoo import _, http, release, service +from odoo.tools.func import lazy_property +from odoo.tools.misc import file_path +from odoo.modules.module import get_resource_path + +_logger = logging.getLogger(__name__) + +try: + import crypt +except ImportError: + _logger.warning('Could not import library crypt') + +#---------------------------------------------------------- +# Helper +#---------------------------------------------------------- + + +class CertificateStatus(Enum): + OK = 1 + NEED_REFRESH = 2 + ERROR = 3 + + +class IoTRestart(Thread): + """ + Thread to restart odoo server in IoT Box when we must return a answer before + """ + def __init__(self, delay): + Thread.__init__(self) + self.delay = delay + + def run(self): + time.sleep(self.delay) + service.server.restart() + + +if platform.system() == 'Windows': + writable = contextlib.nullcontext +elif platform.system() == 'Linux': + @contextlib.contextmanager + def writable(): + subprocess.call(["sudo", "mount", "-o", "remount,rw", "/"]) + subprocess.call(["sudo", "mount", "-o", "remount,rw", "/root_bypass_ramdisks/"]) + try: + yield + finally: + subprocess.call(["sudo", "mount", "-o", "remount,ro", "/"]) + subprocess.call(["sudo", "mount", "-o", "remount,ro", "/root_bypass_ramdisks/"]) + subprocess.call(["sudo", "mount", "-o", "remount,rw", "/root_bypass_ramdisks/etc/cups"]) + +def access_point(): + return get_ip() == '10.11.12.1' + +def start_nginx_server(): + if platform.system() == 'Windows': + path_nginx = get_path_nginx() + if path_nginx: + os.chdir(path_nginx) + _logger.info('Start Nginx server: %s\\nginx.exe', path_nginx) + os.popen('nginx.exe') + os.chdir('..\\server') + elif platform.system() == 'Linux': + subprocess.check_call(["sudo", "service", "nginx", "restart"]) + +def check_certificate(): + """ + Check if the current certificate is up to date or not authenticated + :return CheckCertificateStatus + """ + server = get_odoo_server_url() + + if not server: + return {"status": CertificateStatus.ERROR, + "error_code": "ERR_IOT_HTTPS_CHECK_NO_SERVER"} + + if platform.system() == 'Windows': + path = Path(get_path_nginx()).joinpath('conf/nginx-cert.crt') + elif platform.system() == 'Linux': + path = Path('/etc/ssl/certs/nginx-cert.crt') + + if not path.exists(): + return {"status": CertificateStatus.NEED_REFRESH} + + try: + with path.open('r') as f: + cert = crypto.load_certificate(crypto.FILETYPE_PEM, f.read()) + except EnvironmentError: + _logger.exception("Unable to read certificate file") + return {"status": CertificateStatus.ERROR, + "error_code": "ERR_IOT_HTTPS_CHECK_CERT_READ_EXCEPTION"} + + cert_end_date = datetime.datetime.strptime(cert.get_notAfter().decode('utf-8'), "%Y%m%d%H%M%SZ") - datetime.timedelta(days=10) + for key in cert.get_subject().get_components(): + if key[0] == b'CN': + cn = key[1].decode('utf-8') + if cn == 'OdooTempIoTBoxCertificate' or datetime.datetime.now() > cert_end_date: + message = _('Your certificate %s must be updated') % (cn) + _logger.info(message) + return {"status": CertificateStatus.NEED_REFRESH} + else: + message = _('Your certificate %s is valid until %s') % (cn, cert_end_date) + _logger.info(message) + return {"status": CertificateStatus.OK, "message": message} + +def check_git_branch(): + """ + Check if the local branch is the same than the connected Odoo DB and + checkout to match it if needed. + """ + server = get_odoo_server_url() + urllib3.disable_warnings() + http = urllib3.PoolManager(cert_reqs='CERT_NONE') + try: + response = http.request('POST', + server + "/web/webclient/version_info", + body='{}', + headers={'Content-type': 'application/json'} + ) + + if response.status == 200: + git = ['git', '--work-tree=/home/pi/odoo/', '--git-dir=/home/pi/odoo/.git'] + + db_branch = json.loads(response.data)['result']['server_serie'].replace('~', '-') + if not subprocess.check_output(git + ['ls-remote', 'origin', db_branch]): + db_branch = 'master' + + local_branch = subprocess.check_output(git + ['symbolic-ref', '-q', '--short', 'HEAD']).decode('utf-8').rstrip() + _logger.info("Current IoT Box local git branch: %s / Associated Odoo database's git branch: %s", local_branch, db_branch) + + if db_branch != local_branch: + with writable(): + subprocess.check_call(["rm", "-rf", "/home/pi/odoo/addons/hw_drivers/iot_handlers/drivers/*"]) + subprocess.check_call(["rm", "-rf", "/home/pi/odoo/addons/hw_drivers/iot_handlers/interfaces/*"]) + subprocess.check_call(git + ['branch', '-m', db_branch]) + subprocess.check_call(git + ['remote', 'set-branches', 'origin', db_branch]) + os.system('/home/pi/odoo/addons/point_of_sale/tools/posbox/configuration/posbox_update.sh') + + except Exception: + _logger.exception('An error occurred while trying to update the code with git') + +def check_image(): + """ + Check if the current image of IoT Box is up to date + """ + url = 'https://nightly.odoo.com/master/iotbox/SHA1SUMS.txt' + urllib3.disable_warnings() + http = urllib3.PoolManager(cert_reqs='CERT_NONE') + response = http.request('GET', url) + checkFile = {} + valueActual = '' + for line in response.data.decode().split('\n'): + if line: + value, name = line.split(' ') + checkFile.update({value: name}) + if name == 'iotbox-latest.zip': + valueLastest = value + elif name == get_img_name(): + valueActual = value + if valueActual == valueLastest: + return False + version = checkFile.get(valueLastest, 'Error').replace('iotboxv', '').replace('.zip', '').split('_') + return {'major': version[0], 'minor': version[1]} + +def save_conf_server(url, token, db_uuid, enterprise_code): + """ + Save config to connect IoT to the server + """ + write_file('odoo-remote-server.conf', url) + write_file('token', token) + write_file('odoo-db-uuid.conf', db_uuid or '') + write_file('odoo-enterprise-code.conf', enterprise_code or '') + +def generate_password(): + """ + Generate an unique code to secure raspberry pi + """ + alphabet = 'abcdefghijkmnpqrstuvwxyz23456789' + password = ''.join(secrets.choice(alphabet) for i in range(12)) + try: + shadow_password = crypt.crypt(password, crypt.mksalt()) + subprocess.run(('sudo', 'usermod', '-p', shadow_password, 'pi'), check=True) + with writable(): + subprocess.run(('sudo', 'cp', '/etc/shadow', '/root_bypass_ramdisks/etc/shadow'), check=True) + return password + except subprocess.CalledProcessError as e: + _logger.exception("Failed to generate password: %s", e.output) + return 'Error: Check IoT log' + + +def get_certificate_status(is_first=True): + """ + Will get the HTTPS certificate details if present. Will load the certificate if missing. + + :param is_first: Use to make sure that the recursion happens only once + :return: (bool, str) + """ + check_certificate_result = check_certificate() + certificateStatus = check_certificate_result["status"] + + if certificateStatus == CertificateStatus.ERROR: + return False, check_certificate_result["error_code"] + + if certificateStatus == CertificateStatus.NEED_REFRESH and is_first: + certificate_process = load_certificate() + if certificate_process is not True: + return False, certificate_process + return get_certificate_status(is_first=False) # recursive call to attempt certificate read + return True, check_certificate_result.get("message", + "The HTTPS certificate was generated correctly") + +def get_img_name(): + major, minor = get_version()[1:].split('.') + return 'iotboxv%s_%s.zip' % (major, minor) + +def get_ip(): + interfaces = netifaces.interfaces() + for interface in interfaces: + if netifaces.ifaddresses(interface).get(netifaces.AF_INET): + addr = netifaces.ifaddresses(interface).get(netifaces.AF_INET)[0]['addr'] + if addr != '127.0.0.1': + return addr + +def get_mac_address(): + interfaces = netifaces.interfaces() + for interface in interfaces: + if netifaces.ifaddresses(interface).get(netifaces.AF_INET): + addr = netifaces.ifaddresses(interface).get(netifaces.AF_LINK)[0]['addr'] + if addr != '00:00:00:00:00:00': + return addr + +def get_path_nginx(): + return str(list(Path().absolute().parent.glob('*nginx*'))[0]) + +def get_ssid(): + ap = subprocess.call(['systemctl', 'is-active', '--quiet', 'hostapd']) # if service is active return 0 else inactive + if not ap: + return subprocess.check_output(['grep', '-oP', '(?<=ssid=).*', '/etc/hostapd/hostapd.conf']).decode('utf-8').rstrip() + process_iwconfig = subprocess.Popen(['iwconfig'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + process_grep = subprocess.Popen(['grep', 'ESSID:"'], stdin=process_iwconfig.stdout, stdout=subprocess.PIPE) + return subprocess.check_output(['sed', 's/.*"\\(.*\\)"/\\1/'], stdin=process_grep.stdout).decode('utf-8').rstrip() + +def get_odoo_server_url(): + if platform.system() == 'Linux': + ap = subprocess.call(['systemctl', 'is-active', '--quiet', 'hostapd']) # if service is active return 0 else inactive + if not ap: + return False + return read_file_first_line('odoo-remote-server.conf') + +def get_token(): + return read_file_first_line('token') + + +def get_commit_hash(): + return subprocess.run( + ['git', '--work-tree=/home/pi/odoo/', '--git-dir=/home/pi/odoo/.git', 'rev-parse', '--short', 'HEAD'], + stdout=subprocess.PIPE, + check=True, + ).stdout.decode('ascii').strip() + + +def get_version(detailed_version=False): + if platform.system() == 'Linux': + image_version = read_file_first_line('/var/odoo/iotbox_version') + elif platform.system() == 'Windows': + # updated manually when big changes are made to the windows virtual IoT + image_version = '22.11' + + version = platform.system()[0] + image_version + if detailed_version: + # Note: on windows IoT, the `release.version` finish with the build date + version += f"-{release.version}" + if platform.system() == 'Linux': + version += f'#{get_commit_hash()}' + return version + +def get_wifi_essid(): + wifi_options = [] + process_iwlist = subprocess.Popen(['sudo', 'iwlist', 'wlan0', 'scan'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + process_grep = subprocess.Popen(['grep', 'ESSID:"'], stdin=process_iwlist.stdout, stdout=subprocess.PIPE).stdout.readlines() + for ssid in process_grep: + essid = ssid.decode('utf-8').split('"')[1] + if essid not in wifi_options: + wifi_options.append(essid) + return wifi_options + +def load_certificate(): + """ + Send a request to Odoo with customer db_uuid and enterprise_code to get a true certificate + """ + db_uuid = read_file_first_line('odoo-db-uuid.conf') + enterprise_code = read_file_first_line('odoo-enterprise-code.conf') + if not db_uuid: + return "ERR_IOT_HTTPS_LOAD_NO_CREDENTIAL" + + url = 'https://www.odoo.com/odoo-enterprise/iot/x509' + data = { + 'params': { + 'db_uuid': db_uuid, + 'enterprise_code': enterprise_code or '' + } + } + urllib3.disable_warnings() + http = urllib3.PoolManager(cert_reqs='CERT_NONE', retries=urllib3.Retry(4)) + try: + response = http.request( + 'POST', + url, + body = json.dumps(data).encode('utf8'), + headers = {'Content-type': 'application/json', 'Accept': 'text/plain'} + ) + except Exception as e: + _logger.exception("An error occurred while trying to reach odoo.com servers.") + return "ERR_IOT_HTTPS_LOAD_REQUEST_EXCEPTION\n\n%s" % e + + if response.status != 200: + return "ERR_IOT_HTTPS_LOAD_REQUEST_STATUS %s\n\n%s" % (response.status, response.reason) + + response_body = json.loads(response.data.decode()) + server_error = response_body.get('error') + if server_error: + _logger.error("A server error received from odoo.com while trying to get the certificate: %s", server_error) + return "ERR_IOT_HTTPS_LOAD_REQUEST_NO_RESULT" + + result = response_body.get('result', {}) + certificate_error = result.get('error') + if certificate_error: + _logger.error("An error received from odoo.com while trying to get the certificate: %s", certificate_error) + return "ERR_IOT_HTTPS_LOAD_REQUEST_NO_RESULT" + + write_file('odoo-subject.conf', result['subject_cn']) + if platform.system() == 'Linux': + with writable(): + Path('/etc/ssl/certs/nginx-cert.crt').write_text(result['x509_pem']) + Path('/root_bypass_ramdisks/etc/ssl/certs/nginx-cert.crt').write_text(result['x509_pem']) + Path('/etc/ssl/private/nginx-cert.key').write_text(result['private_key_pem']) + Path('/root_bypass_ramdisks/etc/ssl/private/nginx-cert.key').write_text(result['private_key_pem']) + elif platform.system() == 'Windows': + Path(get_path_nginx()).joinpath('conf/nginx-cert.crt').write_text(result['x509_pem']) + Path(get_path_nginx()).joinpath('conf/nginx-cert.key').write_text(result['private_key_pem']) + time.sleep(3) + if platform.system() == 'Windows': + odoo_restart(0) + elif platform.system() == 'Linux': + start_nginx_server() + return True + +def delete_iot_handlers(): + """ + Delete all the drivers and interfaces + This is needed to avoid conflicts + with the newly downloaded drivers + """ + try: + for directory in ['drivers', 'interfaces']: + path = file_path(f'hw_drivers/iot_handlers/{directory}') + iot_handlers = list_file_by_os(path) + for file in iot_handlers: + unlink_file(f"odoo/addons/hw_drivers/iot_handlers/{directory}/{file}") + _logger.info("Deleted old IoT handlers") + except OSError: + _logger.exception('Failed to delete old IoT handlers') + +def download_iot_handlers(auto=True): + """ + Get the drivers from the configured Odoo server + """ + server = get_odoo_server_url() + if server: + urllib3.disable_warnings() + pm = urllib3.PoolManager(cert_reqs='CERT_NONE') + server = server + '/iot/get_handlers' + try: + resp = pm.request('POST', server, fields={'mac': get_mac_address(), 'auto': auto}, timeout=8) + if resp.data: + delete_iot_handlers() + with writable(): + path = path_file('odoo', 'addons', 'hw_drivers', 'iot_handlers') + zip_file = zipfile.ZipFile(io.BytesIO(resp.data)) + zip_file.extractall(path) + except Exception: + _logger.exception('Could not reach configured server to download IoT handlers') + +def compute_iot_handlers_addon_name(handler_kind, handler_file_name): + # TODO: replace with `removesuffix` (for Odoo version using an IoT image that use Python >= 3.9) + return "odoo.addons.hw_drivers.iot_handlers.{handler_kind}.{handler_name}".\ + format(handler_kind=handler_kind, handler_name=handler_file_name.replace('.py', '')) + +def load_iot_handlers(): + """ + This method loads local files: 'odoo/addons/hw_drivers/iot_handlers/drivers' and + 'odoo/addons/hw_drivers/iot_handlers/interfaces' + And execute these python drivers and interfaces + """ + for directory in ['interfaces', 'drivers']: + path = get_resource_path('hw_drivers', 'iot_handlers', directory) + filesList = list_file_by_os(path) + for file in filesList: + spec = util.spec_from_file_location(compute_iot_handlers_addon_name(directory, file), str(Path(path).joinpath(file))) + if spec: + module = util.module_from_spec(spec) + try: + spec.loader.exec_module(module) + except Exception: + _logger.exception('Unable to load handler file: %s', file) + lazy_property.reset_all(http.root) + +def list_file_by_os(file_list): + platform_os = platform.system() + if platform_os == 'Linux': + return [x.name for x in Path(file_list).glob('*[!W].*')] + elif platform_os == 'Windows': + return [x.name for x in Path(file_list).glob('*[!L].*')] + +def odoo_restart(delay): + IR = IoTRestart(delay) + IR.start() + +def path_file(*args): + """Return the path to the file from IoT Box root or Windows Odoo + server folder + :return: The path to the file + """ + platform_os = platform.system() + if platform_os == 'Linux': + return Path("~pi", *args).expanduser() # Path.home() returns odoo user's home instead of pi's + elif platform_os == 'Windows': + return Path().absolute().parent.joinpath('server', *args) + +def read_file_first_line(filename): + path = path_file(filename) + if path.exists(): + with path.open('r') as f: + return f.readline().strip('\n') + +def unlink_file(filename): + with writable(): + path = path_file(filename) + if path.exists(): + path.unlink() + +def write_file(filename, text, mode='w'): + with writable(): + path = path_file(filename) + with open(path, mode) as f: + f.write(text) + +def download_from_url(download_url, path_to_filename): + """ + This function downloads from its 'download_url' argument and + saves the result in 'path_to_filename' file + The 'path_to_filename' needs to be a valid path + file name + (Example: 'C:\\Program Files\\Odoo\\downloaded_file.zip') + """ + try: + request_response = requests.get(download_url, timeout=60) + request_response.raise_for_status() + write_file(path_to_filename, request_response.content, 'wb') + _logger.info('Downloaded %s from %s', path_to_filename, download_url) + except Exception: + _logger.exception('Failed to download from %s', download_url) + +def unzip_file(path_to_filename, path_to_extract): + """ + This function unzips 'path_to_filename' argument to + the path specified by 'path_to_extract' argument + and deletes the originally used .zip file + Example: unzip_file('C:\\Program Files\\Odoo\\downloaded_file.zip', 'C:\\Program Files\\Odoo\\new_folder')) + Will extract all the contents of 'downloaded_file.zip' to the 'new_folder' location) + """ + try: + with writable(): + path = path_file(path_to_filename) + with zipfile.ZipFile(path) as zip_file: + zip_file.extractall(path_file(path_to_extract)) + Path(path).unlink() + _logger.info('Unzipped %s to %s', path_to_filename, path_to_extract) + except Exception: + _logger.exception('Failed to unzip %s', path_to_filename) diff --git a/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/views/pos_display.html b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/views/pos_display.html new file mode 100644 index 0000000..e57d242 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/hw_drivers/views/pos_display.html @@ -0,0 +1,93 @@ + + + + + + {{ title or "Odoo's IoTBox" }} + + + + + + + + +
+
+ +

IoTBox

+
+
+ {% if pairing_code %} +
+

Pairing Code

+
+

{{ pairing_code }}

+
+ {% endif %} +
+

POS Client display

+ + + + + + + + + {% for display_iface in display_ifaces -%} + + + + + {%- endfor %} + +
InterfaceIP
{{ display_iface.essid }}{{ display_iface.addr }}
+

+ The customer cart will be displayed here once a Point of Sale session is started. +

+

+ Odoo version 11 or above is required. +

+ +
+
+ + diff --git a/odoo-bringout-oca-ocb-hw_drivers/pyproject.toml b/odoo-bringout-oca-ocb-hw_drivers/pyproject.toml new file mode 100644 index 0000000..0c3778d --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_drivers/pyproject.toml @@ -0,0 +1,41 @@ +[project] +name = "odoo-bringout-oca-ocb-hw_drivers" +version = "16.0.0" +description = "Hardware Proxy - Connect the Web Client to Hardware Peripherals" +authors = [ + { name = "Ernad Husremovic", email = "hernad@bring.out.ba" } +] +dependencies = [ + "requests>=2.25.1" +] +readme = "README.md" +requires-python = ">= 3.11" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Office/Business", +] + +[project.urls] +homepage = "https://github.com/bringout/0" +repository = "https://github.com/bringout/0" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.build.targets.wheel] +packages = ["hw_drivers"] + +[tool.rye] +managed = true +dev-dependencies = [ + "pytest>=8.4.1", +] diff --git a/odoo-bringout-oca-ocb-hw_escpos/README.md b/odoo-bringout-oca-ocb-hw_escpos/README.md new file mode 100644 index 0000000..26cee75 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/README.md @@ -0,0 +1,52 @@ +# ESC/POS Hardware Driver + + +ESC/POS Hardware Driver +======================= + +This module allows Odoo to print with ESC/POS compatible printers and +to open ESC/POS controlled cashdrawers in the point of sale and other modules +that would need such functionality. + + + +## Installation + +```bash +pip install odoo-bringout-oca-ocb-hw_escpos +``` + +## Dependencies + +This addon depends on: + + +## Manifest Information + +- **Name**: ESC/POS Hardware Driver +- **Version**: N/A +- **Category**: Sales/Point of Sale +- **License**: LGPL-3 +- **Installable**: False + +## Source + +Based on [OCA/OCB](https://github.com/OCA/OCB) branch 16.0, addon `hw_escpos`. + +## License + +This package maintains the original LGPL-3 license from the upstream Odoo project. + +## Documentation + +- Overview: doc/OVERVIEW.md +- Architecture: doc/ARCHITECTURE.md +- Models: doc/MODELS.md +- Controllers: doc/CONTROLLERS.md +- Wizards: doc/WIZARDS.md +- Install: doc/INSTALL.md +- Usage: doc/USAGE.md +- Configuration: doc/CONFIGURATION.md +- Dependencies: doc/DEPENDENCIES.md +- Troubleshooting: doc/TROUBLESHOOTING.md +- FAQ: doc/FAQ.md diff --git a/odoo-bringout-oca-ocb-hw_escpos/doc/ARCHITECTURE.md b/odoo-bringout-oca-ocb-hw_escpos/doc/ARCHITECTURE.md new file mode 100644 index 0000000..d3e8908 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/doc/ARCHITECTURE.md @@ -0,0 +1,32 @@ +# Architecture + +```mermaid +flowchart TD + U[Users] -->|HTTP| V[Views and QWeb Templates] + V --> C[Controllers] + V --> W[Wizards – Transient Models] + C --> M[Models and ORM] + W --> M + M --> R[Reports] + DX[Data XML] --> M + S[Security – ACLs and Groups] -. enforces .-> M + + subgraph Hw_escpos Module - hw_escpos + direction LR + M:::layer + W:::layer + C:::layer + V:::layer + R:::layer + S:::layer + DX:::layer + end + + classDef layer fill:#eef8ff,stroke:#6ea8fe,stroke-width:1px +``` + +Notes +- Views include tree/form/kanban templates and report templates. +- Controllers provide website/portal routes when present. +- Wizards are UI flows implemented with `models.TransientModel`. +- Data XML loads data/demo records; Security defines groups and access. diff --git a/odoo-bringout-oca-ocb-hw_escpos/doc/CONFIGURATION.md b/odoo-bringout-oca-ocb-hw_escpos/doc/CONFIGURATION.md new file mode 100644 index 0000000..8b2b8b7 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/doc/CONFIGURATION.md @@ -0,0 +1,3 @@ +# Configuration + +Refer to Odoo settings for hw_escpos. Configure related models, access rights, and options as needed. diff --git a/odoo-bringout-oca-ocb-hw_escpos/doc/CONTROLLERS.md b/odoo-bringout-oca-ocb-hw_escpos/doc/CONTROLLERS.md new file mode 100644 index 0000000..ff097c0 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/doc/CONTROLLERS.md @@ -0,0 +1,17 @@ +# Controllers + +HTTP routes provided by this module. + +```mermaid +sequenceDiagram + participant U as User/Client + participant C as Module Controllers + participant O as ORM/Views + + U->>C: HTTP GET/POST (routes) + C->>O: ORM operations, render templates + O-->>U: HTML/JSON/PDF +``` + +Notes +- See files in controllers/ for route definitions. diff --git a/odoo-bringout-oca-ocb-hw_escpos/doc/DEPENDENCIES.md b/odoo-bringout-oca-ocb-hw_escpos/doc/DEPENDENCIES.md new file mode 100644 index 0000000..3b4b1b6 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/doc/DEPENDENCIES.md @@ -0,0 +1,3 @@ +# Dependencies + +No explicit module dependencies declared. diff --git a/odoo-bringout-oca-ocb-hw_escpos/doc/FAQ.md b/odoo-bringout-oca-ocb-hw_escpos/doc/FAQ.md new file mode 100644 index 0000000..cfec0dc --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/doc/FAQ.md @@ -0,0 +1,4 @@ +# FAQ + +- Q: Which Odoo version? A: 16.0 (OCA/OCB packaged). +- Q: How to enable? A: Start server with --addon hw_escpos or install in UI. diff --git a/odoo-bringout-oca-ocb-hw_escpos/doc/INSTALL.md b/odoo-bringout-oca-ocb-hw_escpos/doc/INSTALL.md new file mode 100644 index 0000000..ba659bf --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/doc/INSTALL.md @@ -0,0 +1,7 @@ +# Install + +```bash +pip install odoo-bringout-oca-ocb-hw_escpos" +# or +uv pip install odoo-bringout-oca-ocb-hw_escpos" +``` diff --git a/odoo-bringout-oca-ocb-hw_escpos/doc/MODELS.md b/odoo-bringout-oca-ocb-hw_escpos/doc/MODELS.md new file mode 100644 index 0000000..3ae135f --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/doc/MODELS.md @@ -0,0 +1,11 @@ +# Models + +Detected core models and extensions in hw_escpos. + +```mermaid +classDiagram +``` + +Notes +- Classes show model technical names; fields omitted for brevity. +- Items listed under _inherit are extensions of existing models. diff --git a/odoo-bringout-oca-ocb-hw_escpos/doc/OVERVIEW.md b/odoo-bringout-oca-ocb-hw_escpos/doc/OVERVIEW.md new file mode 100644 index 0000000..60c89b1 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/doc/OVERVIEW.md @@ -0,0 +1,6 @@ +# Overview + +Packaged Odoo addon: hw_escpos. Provides features documented in upstream Odoo 16 under this addon. + +- Source: OCA/OCB 16.0, addon hw_escpos +- License: LGPL-3 diff --git a/odoo-bringout-oca-ocb-hw_escpos/doc/REPORTS.md b/odoo-bringout-oca-ocb-hw_escpos/doc/REPORTS.md new file mode 100644 index 0000000..e0ea35f --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/doc/REPORTS.md @@ -0,0 +1,3 @@ +# Reports + +This module does not define custom reports. diff --git a/odoo-bringout-oca-ocb-hw_escpos/doc/SECURITY.md b/odoo-bringout-oca-ocb-hw_escpos/doc/SECURITY.md new file mode 100644 index 0000000..e07da9d --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/doc/SECURITY.md @@ -0,0 +1,8 @@ +# Security + +This module does not define custom security rules or access controls beyond Odoo defaults. + +Default Odoo security applies: +- Base user access through standard groups +- Model access inherited from dependencies +- No custom row-level security rules diff --git a/odoo-bringout-oca-ocb-hw_escpos/doc/TROUBLESHOOTING.md b/odoo-bringout-oca-ocb-hw_escpos/doc/TROUBLESHOOTING.md new file mode 100644 index 0000000..56853cb --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/doc/TROUBLESHOOTING.md @@ -0,0 +1,5 @@ +# Troubleshooting + +- Ensure Python and Odoo environment matches repo guidance. +- Check database connectivity and logs if startup fails. +- Validate that dependent addons listed in DEPENDENCIES.md are installed. diff --git a/odoo-bringout-oca-ocb-hw_escpos/doc/USAGE.md b/odoo-bringout-oca-ocb-hw_escpos/doc/USAGE.md new file mode 100644 index 0000000..f63c25f --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/doc/USAGE.md @@ -0,0 +1,7 @@ +# Usage + +Start Odoo including this addon (from repo root): + +```bash +python3 scripts/nix_odoo_web_server.py --db-name mydb --addon hw_escpos +``` diff --git a/odoo-bringout-oca-ocb-hw_escpos/doc/WIZARDS.md b/odoo-bringout-oca-ocb-hw_escpos/doc/WIZARDS.md new file mode 100644 index 0000000..48e790d --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/doc/WIZARDS.md @@ -0,0 +1,3 @@ +# Wizards + +This module does not include UI wizards. diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/README.md b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/README.md new file mode 100644 index 0000000..792571a --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/README.md @@ -0,0 +1,4 @@ +The escpos directory contains the MIT licensed pyxmlescpos lib taken from + +https://github.com/fvdsn/py-xml-escpos + diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/__init__.py b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/__init__.py new file mode 100644 index 0000000..175e48a --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import controllers +from . import escpos diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/__manifest__.py b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/__manifest__.py new file mode 100644 index 0000000..1b0d5d9 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/__manifest__.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'ESC/POS Hardware Driver', + 'category': 'Sales/Point of Sale', + 'sequence': 6, + 'website': 'https://www.odoo.com/app/point-of-sale-hardware', + 'summary': 'Hardware Driver for ESC/POS Printers and Cashdrawers', + 'description': """ +ESC/POS Hardware Driver +======================= + +This module allows Odoo to print with ESC/POS compatible printers and +to open ESC/POS controlled cashdrawers in the point of sale and other modules +that would need such functionality. + +""", + 'external_dependencies': { + 'python' : ['pyusb','pyserial','qrcode'], + }, + 'installable': False, + 'license': 'LGPL-3', +} diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/controllers/__init__.py b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/controllers/__init__.py new file mode 100644 index 0000000..5d4b25d --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/controllers/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import main diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/controllers/main.py b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/controllers/main.py new file mode 100644 index 0000000..d24cdb0 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/controllers/main.py @@ -0,0 +1,344 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from __future__ import print_function +import logging +import math +import os +import os.path +import subprocess +import time +import netifaces as ni +import traceback + +escpos = printer = None +try: + from .. escpos import * + from .. escpos.exceptions import * + from .. escpos.printer import Usb +except ImportError: + pass + +from queue import Queue +from threading import Thread, Lock + +try: + import usb.core +except ImportError: + usb = None + +from odoo import http, _ +from odoo.addons.hw_drivers.controllers import proxy + +_logger = logging.getLogger(__name__) + +# workaround https://bugs.launchpad.net/openobject-server/+bug/947231 +# related to http://bugs.python.org/issue7980 +from datetime import datetime +datetime.strptime('2012-01-01', '%Y-%m-%d') + +class EscposDriver(Thread): + def __init__(self): + Thread.__init__(self) + self.queue = Queue() + self.lock = Lock() + self.status = {'status':'connecting', 'messages':[]} + + def connected_usb_devices(self): + connected = [] + + # printers can either define bDeviceClass=7, or they can define one of + # their interfaces with bInterfaceClass=7. This class checks for both. + class FindUsbClass(object): + def __init__(self, usb_class): + self._class = usb_class + def __call__(self, device): + # first, let's check the device + if device.bDeviceClass == self._class: + return True + # transverse all devices and look through their interfaces to + # find a matching class + for cfg in device: + intf = usb.util.find_descriptor(cfg, bInterfaceClass=self._class) + + if intf is not None: + return True + + return False + + printers = usb.core.find(find_all=True, custom_match=FindUsbClass(7)) + + # if no printers are found after this step we will take the + # first epson or star device we can find. + # epson + if not printers: + printers = usb.core.find(find_all=True, idVendor=0x04b8) + # star + if not printers: + printers = usb.core.find(find_all=True, idVendor=0x0519) + + for printer in printers: + try: + description = usb.util.get_string(printer, printer.iManufacturer) + " " + usb.util.get_string(printer, printer.iProduct) + except Exception as e: + _logger.error("Can not get printer description: %s" % e) + description = 'Unknown printer' + connected.append({ + 'vendor': printer.idVendor, + 'product': printer.idProduct, + 'name': description + }) + + return connected + + def lockedstart(self): + with self.lock: + if not self.is_alive(): + self.daemon = True + self.start() + + def get_escpos_printer(self): + + printers = self.connected_usb_devices() + if len(printers) > 0: + try: + print_dev = Usb(printers[0]['vendor'], printers[0]['product']) + except HandleDeviceError: + # Escpos printers are now integrated to PrinterDriver, if the IoTBox is printing + # through Cups at the same time, we get an USBError(16, 'Resource busy'). This means + # that the Odoo instance connected to this IoTBox is up to date and no longer uses + # this escpos library. + return None + self.set_status( + 'connected', + "Connected to %s (in=0x%02x,out=0x%02x)" % (printers[0]['name'], print_dev.in_ep, print_dev.out_ep) + ) + return print_dev + else: + self.set_status('disconnected','Printer Not Found') + return None + + def get_status(self): + self.push_task('status') + return self.status + + def open_cashbox(self,printer): + printer.cashdraw(2) + printer.cashdraw(5) + + def set_status(self, status, message = None): + _logger.info(status+' : '+ (message or 'no message')) + if status == self.status['status']: + if message != None and (len(self.status['messages']) == 0 or message != self.status['messages'][-1]): + self.status['messages'].append(message) + else: + self.status['status'] = status + if message: + self.status['messages'] = [message] + else: + self.status['messages'] = [] + + if status == 'error' and message: + _logger.error('ESC/POS Error: %s', message) + elif status == 'disconnected' and message: + _logger.warning('ESC/POS Device Disconnected: %s', message) + + def run(self): + printer = None + if not escpos: + _logger.error('ESC/POS cannot initialize, please verify system dependencies.') + return + while True: + error = True + try: + timestamp, task, data = self.queue.get(True) + + printer = self.get_escpos_printer() + + if printer == None: + if task != 'status': + self.queue.put((timestamp,task,data)) + error = False + time.sleep(5) + continue + elif task == 'receipt': + if timestamp >= time.time() - 1 * 60 * 60: + self.print_receipt_body(printer,data) + printer.cut() + elif task == 'xml_receipt': + if timestamp >= time.time() - 1 * 60 * 60: + printer.receipt(data) + elif task == 'cashbox': + if timestamp >= time.time() - 12: + self.open_cashbox(printer) + elif task == 'status': + pass + error = False + + except NoDeviceError as e: + print("No device found %s" % e) + except HandleDeviceError as e: + printer = None + print("Impossible to handle the device due to previous error %s" % e) + except TicketNotPrinted as e: + print("The ticket does not seems to have been fully printed %s" % e) + except NoStatusError as e: + print("Impossible to get the status of the printer %s" % e) + except Exception as e: + self.set_status('error') + _logger.exception(e) + finally: + if error: + self.queue.put((timestamp, task, data)) + if printer: + printer.close() + printer = None + + def push_task(self,task, data = None): + self.lockedstart() + self.queue.put((time.time(),task,data)) + + def print_receipt_body(self,eprint,receipt): + + def check(string): + return string != True and bool(string) and string.strip() + + def price(amount): + return ("{0:."+str(receipt['precision']['price'])+"f}").format(amount) + + def money(amount): + return ("{0:."+str(receipt['precision']['money'])+"f}").format(amount) + + def quantity(amount): + if math.floor(amount) != amount: + return ("{0:."+str(receipt['precision']['quantity'])+"f}").format(amount) + else: + return str(amount) + + def printline(left, right='', width=40, ratio=0.5, indent=0): + lwidth = int(width * ratio) + rwidth = width - lwidth + lwidth = lwidth - indent + + left = left[:lwidth] + if len(left) != lwidth: + left = left + ' ' * (lwidth - len(left)) + + right = right[-rwidth:] + if len(right) != rwidth: + right = ' ' * (rwidth - len(right)) + right + + return ' ' * indent + left + right + '\n' + + def print_taxes(): + taxes = receipt['tax_details'] + for tax in taxes: + eprint.text(printline(tax['tax']['name'],price(tax['amount']), width=40,ratio=0.6)) + + # Receipt Header + if receipt['company']['logo']: + eprint.set(align='center') + eprint.print_base64_image(receipt['company']['logo']) + eprint.text('\n') + else: + eprint.set(align='center',type='b',height=2,width=2) + eprint.text(receipt['company']['name'] + '\n') + + eprint.set(align='center',type='b') + if check(receipt['company']['contact_address']): + eprint.text(receipt['company']['contact_address'] + '\n') + if check(receipt['company']['phone']): + eprint.text('Tel:' + receipt['company']['phone'] + '\n') + if check(receipt['company']['vat']): + eprint.text('VAT:' + receipt['company']['vat'] + '\n') + if check(receipt['company']['email']): + eprint.text(receipt['company']['email'] + '\n') + if check(receipt['company']['website']): + eprint.text(receipt['company']['website'] + '\n') + if check(receipt['header']): + eprint.text(receipt['header']+'\n') + if check(receipt['cashier']): + eprint.text('-'*32+'\n') + eprint.text('Served by '+receipt['cashier']+'\n') + + # Orderlines + eprint.text('\n\n') + eprint.set(align='center') + for line in receipt['orderlines']: + pricestr = price(line['price_display']) + if line['discount'] == 0 and line['unit_name'] == 'Units' and line['quantity'] == 1: + eprint.text(printline(line['product_name'],pricestr,ratio=0.6)) + else: + eprint.text(printline(line['product_name'],ratio=0.6)) + if line['discount'] != 0: + eprint.text(printline('Discount: '+str(line['discount'])+'%', ratio=0.6, indent=2)) + if line['unit_name'] == 'Units': + eprint.text( printline( quantity(line['quantity']) + ' x ' + price(line['price']), pricestr, ratio=0.6, indent=2)) + else: + eprint.text( printline( quantity(line['quantity']) + line['unit_name'] + ' x ' + price(line['price']), pricestr, ratio=0.6, indent=2)) + + # Subtotal if the taxes are not included + taxincluded = True + if money(receipt['subtotal']) != money(receipt['total_with_tax']): + eprint.text(printline('', '-------')) + eprint.text(printline(_('Subtotal'),money(receipt['subtotal']),width=40, ratio=0.6)) + print_taxes() + #eprint.text(printline(_('Taxes'),money(receipt['total_tax']),width=40, ratio=0.6)) + taxincluded = False + + # Total + eprint.text(printline('', '-------')) + eprint.set(align='center',height=2) + eprint.text(printline(_(' TOTAL'),money(receipt['total_with_tax']),width=40, ratio=0.6)) + eprint.text('\n\n') + + # Paymentlines + eprint.set(align='center') + for line in receipt['paymentlines']: + eprint.text(printline(line['journal'], money(line['amount']), ratio=0.6)) + + eprint.text('\n') + eprint.set(align='center',height=2) + eprint.text(printline(_(' CHANGE'),money(receipt['change']),width=40, ratio=0.6)) + eprint.set(align='center') + eprint.text('\n') + + # Extra Payment info + if receipt['total_discount'] != 0: + eprint.text(printline(_('Discounts'),money(receipt['total_discount']),width=40, ratio=0.6)) + if taxincluded: + print_taxes() + #eprint.text(printline(_('Taxes'),money(receipt['total_tax']),width=40, ratio=0.6)) + + # Footer + if check(receipt['footer']): + eprint.text('\n'+receipt['footer']+'\n\n') + eprint.text(receipt['name']+'\n') + eprint.text( str(receipt['date']['date']).zfill(2) + +'/'+ str(receipt['date']['month']+1).zfill(2) + +'/'+ str(receipt['date']['year']).zfill(4) + +' '+ str(receipt['date']['hour']).zfill(2) + +':'+ str(receipt['date']['minute']).zfill(2) ) + + +driver = EscposDriver() + +proxy.proxy_drivers['escpos'] = driver + + +class EscposProxy(proxy.ProxyController): + + @http.route('/hw_proxy/open_cashbox', type='json', auth='none', cors='*') + def open_cashbox(self): + _logger.info('ESC/POS: OPEN CASHBOX') + driver.push_task('cashbox') + + @http.route('/hw_proxy/print_receipt', type='json', auth='none', cors='*') + def print_receipt(self, receipt): + _logger.info('ESC/POS: PRINT RECEIPT') + driver.push_task('receipt',receipt) + + @http.route('/hw_proxy/print_xml_receipt', type='json', auth='none', cors='*') + def print_xml_receipt(self, receipt): + _logger.info('ESC/POS: PRINT XML RECEIPT') + driver.push_task('xml_receipt',receipt) diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/escpos/LICENSE.txt b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/escpos/LICENSE.txt new file mode 100644 index 0000000..8920ee2 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/escpos/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Frederic van der Essen & Manuel F. Martinez + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/escpos/__init__.py b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/escpos/__init__.py new file mode 100644 index 0000000..22a5af6 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/escpos/__init__.py @@ -0,0 +1 @@ +__all__ = ["constants","escpos","exceptions","printer"] diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/escpos/constants.py b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/escpos/constants.py new file mode 100644 index 0000000..0000109 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/escpos/constants.py @@ -0,0 +1,190 @@ +# -*- coding: utf-8 -*- + +""" ESC/POS Commands (Constants) """ + +# Control characters +ESC = '\x1b' + +# Feed control sequences +CTL_LF = '\x0a' # Print and line feed +CTL_FF = '\x0c' # Form feed +CTL_CR = '\x0d' # Carriage return +CTL_HT = '\x09' # Horizontal tab +CTL_VT = '\x0b' # Vertical tab + +# RT Status commands +DLE_EOT_PRINTER = '\x10\x04\x01' # Transmit printer status +DLE_EOT_OFFLINE = '\x10\x04\x02' +DLE_EOT_ERROR = '\x10\x04\x03' +DLE_EOT_PAPER = '\x10\x04\x04' + +# Printer hardware +HW_INIT = '\x1b\x40' # Clear data in buffer and reset modes +HW_SELECT = '\x1b\x3d\x01' # Printer select +HW_RESET = '\x1b\x3f\x0a\x00' # Reset printer hardware +# Cash Drawer (ESC p ) +_CASH_DRAWER = lambda m, t1='', t2='': ESC + 'p' + m + chr(t1) + chr(t2) +CD_KICK_2 = _CASH_DRAWER('\x00', 50, 50) # Sends a pulse to pin 2 [] +CD_KICK_5 = _CASH_DRAWER('\x01', 50, 50) # Sends a pulse to pin 5 [] +# Paper +PAPER_FULL_CUT = '\x1d\x56\x00' # Full cut paper +PAPER_PART_CUT = '\x1d\x56\x01' # Partial cut paper +# Text format +TXT_NORMAL = '\x1b\x21\x00' # Normal text +TXT_2HEIGHT = '\x1b\x21\x10' # Double height text +TXT_2WIDTH = '\x1b\x21\x20' # Double width text +TXT_DOUBLE = '\x1b\x21\x30' # Double height & Width +TXT_UNDERL_OFF = '\x1b\x2d\x00' # Underline font OFF +TXT_UNDERL_ON = '\x1b\x2d\x01' # Underline font 1-dot ON +TXT_UNDERL2_ON = '\x1b\x2d\x02' # Underline font 2-dot ON +TXT_BOLD_OFF = '\x1b\x45\x00' # Bold font OFF +TXT_BOLD_ON = '\x1b\x45\x01' # Bold font ON +TXT_FONT_A = '\x1b\x4d\x00' # Font type A +TXT_FONT_B = '\x1b\x4d\x01' # Font type B +TXT_ALIGN_LT = '\x1b\x61\x00' # Left justification +TXT_ALIGN_CT = '\x1b\x61\x01' # Centering +TXT_ALIGN_RT = '\x1b\x61\x02' # Right justification +TXT_COLOR_BLACK = '\x1b\x72\x00' # Default Color +TXT_COLOR_RED = '\x1b\x72\x01' # Alternative Color ( Usually Red ) + +# Text Encoding + +TXT_ENC_PC437 = '\x1b\x74\x00' # PC437 USA +TXT_ENC_KATAKANA= '\x1b\x74\x01' # KATAKANA (JAPAN) +TXT_ENC_PC850 = '\x1b\x74\x02' # PC850 Multilingual +TXT_ENC_PC860 = '\x1b\x74\x03' # PC860 Portuguese +TXT_ENC_PC863 = '\x1b\x74\x04' # PC863 Canadian-French +TXT_ENC_PC865 = '\x1b\x74\x05' # PC865 Nordic +TXT_ENC_KANJI6 = '\x1b\x74\x06' # One-pass Kanji, Hiragana +TXT_ENC_KANJI7 = '\x1b\x74\x07' # One-pass Kanji +TXT_ENC_KANJI8 = '\x1b\x74\x08' # One-pass Kanji +TXT_ENC_PC851 = '\x1b\x74\x0b' # PC851 Greek +TXT_ENC_PC853 = '\x1b\x74\x0c' # PC853 Turkish +TXT_ENC_PC857 = '\x1b\x74\x0d' # PC857 Turkish +TXT_ENC_PC737 = '\x1b\x74\x0e' # PC737 Greek +TXT_ENC_8859_7 = '\x1b\x74\x0f' # ISO8859-7 Greek +TXT_ENC_WPC1252 = '\x1b\x74\x10' # WPC1252 +TXT_ENC_PC866 = '\x1b\x74\x11' # PC866 Cyrillic #2 +TXT_ENC_PC852 = '\x1b\x74\x12' # PC852 Latin2 +TXT_ENC_PC858 = '\x1b\x74\x13' # PC858 Euro +TXT_ENC_KU42 = '\x1b\x74\x14' # KU42 Thai +TXT_ENC_TIS11 = '\x1b\x74\x15' # TIS11 Thai +TXT_ENC_TIS18 = '\x1b\x74\x1a' # TIS18 Thai +TXT_ENC_TCVN3 = '\x1b\x74\x1e' # TCVN3 Vietnamese +TXT_ENC_TCVN3B = '\x1b\x74\x1f' # TCVN3 Vietnamese +TXT_ENC_PC720 = '\x1b\x74\x20' # PC720 Arabic +TXT_ENC_WPC775 = '\x1b\x74\x21' # WPC775 Baltic Rim +TXT_ENC_PC855 = '\x1b\x74\x22' # PC855 Cyrillic +TXT_ENC_PC861 = '\x1b\x74\x23' # PC861 Icelandic +TXT_ENC_PC862 = '\x1b\x74\x24' # PC862 Hebrew +TXT_ENC_PC864 = '\x1b\x74\x25' # PC864 Arabic +TXT_ENC_PC869 = '\x1b\x74\x26' # PC869 Greek +TXT_ENC_PC936 = '\x1C\x21\x00' # PC936 GBK(Guobiao Kuozhan) +TXT_ENC_8859_2 = '\x1b\x74\x27' # ISO8859-2 Latin2 +TXT_ENC_8859_9 = '\x1b\x74\x28' # ISO8859-2 Latin9 +TXT_ENC_PC1098 = '\x1b\x74\x29' # PC1098 Farsi +TXT_ENC_PC1118 = '\x1b\x74\x2a' # PC1118 Lithuanian +TXT_ENC_PC1119 = '\x1b\x74\x2b' # PC1119 Lithuanian +TXT_ENC_PC1125 = '\x1b\x74\x2c' # PC1125 Ukrainian +TXT_ENC_WPC1250 = '\x1b\x74\x2d' # WPC1250 Latin2 +TXT_ENC_WPC1251 = '\x1b\x74\x2e' # WPC1251 Cyrillic +TXT_ENC_WPC1253 = '\x1b\x74\x2f' # WPC1253 Greek +TXT_ENC_WPC1254 = '\x1b\x74\x30' # WPC1254 Turkish +TXT_ENC_WPC1255 = '\x1b\x74\x31' # WPC1255 Hebrew +TXT_ENC_WPC1256 = '\x1b\x74\x32' # WPC1256 Arabic +TXT_ENC_WPC1257 = '\x1b\x74\x33' # WPC1257 Baltic Rim +TXT_ENC_WPC1258 = '\x1b\x74\x34' # WPC1258 Vietnamese +TXT_ENC_KZ1048 = '\x1b\x74\x35' # KZ-1048 Kazakhstan + +TXT_ENC_KATAKANA_MAP = { + # Maps UTF-8 Katakana symbols to KATAKANA Page Codes + + # Half-Width Katakanas + '\xef\xbd\xa1':'\xa1', # 。 + '\xef\xbd\xa2':'\xa2', # 「 + '\xef\xbd\xa3':'\xa3', # 」 + '\xef\xbd\xa4':'\xa4', # 、 + '\xef\xbd\xa5':'\xa5', # ・ + + '\xef\xbd\xa6':'\xa6', # ヲ + '\xef\xbd\xa7':'\xa7', # ァ + '\xef\xbd\xa8':'\xa8', # ィ + '\xef\xbd\xa9':'\xa9', # ゥ + '\xef\xbd\xaa':'\xaa', # ェ + '\xef\xbd\xab':'\xab', # ォ + '\xef\xbd\xac':'\xac', # ャ + '\xef\xbd\xad':'\xad', # ュ + '\xef\xbd\xae':'\xae', # ョ + '\xef\xbd\xaf':'\xaf', # ッ + '\xef\xbd\xb0':'\xb0', # ー + '\xef\xbd\xb1':'\xb1', # ア + '\xef\xbd\xb2':'\xb2', # イ + '\xef\xbd\xb3':'\xb3', # ウ + '\xef\xbd\xb4':'\xb4', # エ + '\xef\xbd\xb5':'\xb5', # オ + '\xef\xbd\xb6':'\xb6', # カ + '\xef\xbd\xb7':'\xb7', # キ + '\xef\xbd\xb8':'\xb8', # ク + '\xef\xbd\xb9':'\xb9', # ケ + '\xef\xbd\xba':'\xba', # コ + '\xef\xbd\xbb':'\xbb', # サ + '\xef\xbd\xbc':'\xbc', # シ + '\xef\xbd\xbd':'\xbd', # ス + '\xef\xbd\xbe':'\xbe', # セ + '\xef\xbd\xbf':'\xbf', # ソ + '\xef\xbe\x80':'\xc0', # タ + '\xef\xbe\x81':'\xc1', # チ + '\xef\xbe\x82':'\xc2', # ツ + '\xef\xbe\x83':'\xc3', # テ + '\xef\xbe\x84':'\xc4', # ト + '\xef\xbe\x85':'\xc5', # ナ + '\xef\xbe\x86':'\xc6', # ニ + '\xef\xbe\x87':'\xc7', # ヌ + '\xef\xbe\x88':'\xc8', # ネ + '\xef\xbe\x89':'\xc9', # ノ + '\xef\xbe\x8a':'\xca', # ハ + '\xef\xbe\x8b':'\xcb', # ヒ + '\xef\xbe\x8c':'\xcc', # フ + '\xef\xbe\x8d':'\xcd', # ヘ + '\xef\xbe\x8e':'\xce', # ホ + '\xef\xbe\x8f':'\xcf', # マ + '\xef\xbe\x90':'\xd0', # ミ + '\xef\xbe\x91':'\xd1', # ム + '\xef\xbe\x92':'\xd2', # メ + '\xef\xbe\x93':'\xd3', # モ + '\xef\xbe\x94':'\xd4', # ヤ + '\xef\xbe\x95':'\xd5', # ユ + '\xef\xbe\x96':'\xd6', # ヨ + '\xef\xbe\x97':'\xd7', # ラ + '\xef\xbe\x98':'\xd8', # リ + '\xef\xbe\x99':'\xd9', # ル + '\xef\xbe\x9a':'\xda', # レ + '\xef\xbe\x9b':'\xdb', # ロ + '\xef\xbe\x9c':'\xdc', # ワ + '\xef\xbe\x9d':'\xdd', # ン + + '\xef\xbe\x9e':'\xde', # ゙ + '\xef\xbe\x9f':'\xdf', # ゚ +} + +# Barcod format +BARCODE_TXT_OFF = '\x1d\x48\x00' # HRI barcode chars OFF +BARCODE_TXT_ABV = '\x1d\x48\x01' # HRI barcode chars above +BARCODE_TXT_BLW = '\x1d\x48\x02' # HRI barcode chars below +BARCODE_TXT_BTH = '\x1d\x48\x03' # HRI barcode chars both above and below +BARCODE_FONT_A = '\x1d\x66\x00' # Font type A for HRI barcode chars +BARCODE_FONT_B = '\x1d\x66\x01' # Font type B for HRI barcode chars +BARCODE_HEIGHT = '\x1d\x68\x64' # Barcode Height [1-255] +BARCODE_WIDTH = '\x1d\x77\x03' # Barcode Width [2-6] +BARCODE_UPC_A = '\x1d\x6b\x00' # Barcode type UPC-A +BARCODE_UPC_E = '\x1d\x6b\x01' # Barcode type UPC-E +BARCODE_EAN13 = '\x1d\x6b\x02' # Barcode type EAN13 +BARCODE_EAN8 = '\x1d\x6b\x03' # Barcode type EAN8 +BARCODE_CODE39 = '\x1d\x6b\x04' # Barcode type CODE39 +BARCODE_ITF = '\x1d\x6b\x05' # Barcode type ITF +BARCODE_NW7 = '\x1d\x6b\x06' # Barcode type NW7 +# Image format +S_RASTER_N = '\x1d\x76\x30\x00' # Set raster image normal size +S_RASTER_2W = '\x1d\x76\x30\x01' # Set raster image double width +S_RASTER_2H = '\x1d\x76\x30\x02' # Set raster image double height +S_RASTER_Q = '\x1d\x76\x30\x03' # Set raster image quadruple diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/escpos/escpos.py b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/escpos/escpos.py new file mode 100644 index 0000000..b4bde30 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/escpos/escpos.py @@ -0,0 +1,935 @@ +# -*- coding: utf-8 -*- + +from __future__ import print_function +import base64 +import copy +import io +import math +import re +import traceback +import codecs +from hashlib import md5 + +from PIL import Image +from xml.etree import ElementTree as ET + + +try: + import jcconv +except ImportError: + jcconv = None + +try: + import qrcode +except ImportError: + qrcode = None + +from .constants import * +from .exceptions import * + +def utfstr(stuff): + """ converts stuff to string and does without failing if stuff is a utf8 string """ + if isinstance(stuff, str): + return stuff + else: + return str(stuff) + +class StyleStack: + """ + The stylestack is used by the xml receipt serializer to compute the active styles along the xml + document. Styles are just xml attributes, there is no css mechanism. But the style applied by + the attributes are inherited by deeper nodes. + """ + def __init__(self): + self.stack = [] + self.defaults = { # default style values + 'align': 'left', + 'underline': 'off', + 'bold': 'off', + 'size': 'normal', + 'font' : 'a', + 'width': 48, + 'indent': 0, + 'tabwidth': 2, + 'bullet': ' - ', + 'line-ratio':0.5, + 'color': 'black', + + 'value-decimals': 2, + 'value-symbol': '', + 'value-symbol-position': 'after', + 'value-autoint': 'off', + 'value-decimals-separator': '.', + 'value-thousands-separator': ',', + 'value-width': 0, + + } + + self.types = { # attribute types, default is string and can be ommitted + 'width': 'int', + 'indent': 'int', + 'tabwidth': 'int', + 'line-ratio': 'float', + 'value-decimals': 'int', + 'value-width': 'int', + } + + self.cmds = { + # translation from styles to escpos commands + # some style do not correspond to escpos command are used by + # the serializer instead + 'align': { + 'left': TXT_ALIGN_LT, + 'right': TXT_ALIGN_RT, + 'center': TXT_ALIGN_CT, + '_order': 1, + }, + 'underline': { + 'off': TXT_UNDERL_OFF, + 'on': TXT_UNDERL_ON, + 'double': TXT_UNDERL2_ON, + # must be issued after 'size' command + # because ESC ! resets ESC - + '_order': 10, + }, + 'bold': { + 'off': TXT_BOLD_OFF, + 'on': TXT_BOLD_ON, + # must be issued after 'size' command + # because ESC ! resets ESC - + '_order': 10, + }, + 'font': { + 'a': TXT_FONT_A, + 'b': TXT_FONT_B, + # must be issued after 'size' command + # because ESC ! resets ESC - + '_order': 10, + }, + 'size': { + 'normal': TXT_NORMAL, + 'double-height': TXT_2HEIGHT, + 'double-width': TXT_2WIDTH, + 'double': TXT_DOUBLE, + '_order': 1, + }, + 'color': { + 'black': TXT_COLOR_BLACK, + 'red': TXT_COLOR_RED, + '_order': 1, + }, + } + + self.push(self.defaults) + + def get(self,style): + """ what's the value of a style at the current stack level""" + level = len(self.stack) -1 + while level >= 0: + if style in self.stack[level]: + return self.stack[level][style] + else: + level = level - 1 + return None + + def enforce_type(self, attr, val): + """converts a value to the attribute's type""" + if not attr in self.types: + return utfstr(val) + elif self.types[attr] == 'int': + return int(float(val)) + elif self.types[attr] == 'float': + return float(val) + else: + return utfstr(val) + + def push(self, style={}): + """push a new level on the stack with a style dictionnary containing style:value pairs""" + _style = {} + for attr in style: + if attr in self.cmds and not style[attr] in self.cmds[attr]: + print('WARNING: ESC/POS PRINTING: ignoring invalid value: %s for style %s' % (style[attr], utfstr(attr))) + else: + _style[attr] = self.enforce_type(attr, style[attr]) + self.stack.append(_style) + + def set(self, style={}): + """overrides style values at the current stack level""" + _style = {} + for attr in style: + if attr in self.cmds and not style[attr] in self.cmds[attr]: + print('WARNING: ESC/POS PRINTING: ignoring invalid value: %s for style %s' % (style[attr], attr)) + else: + self.stack[-1][attr] = self.enforce_type(attr, style[attr]) + + def pop(self): + """ pop a style stack level """ + if len(self.stack) > 1 : + self.stack = self.stack[:-1] + + def to_escpos(self): + """ converts the current style to an escpos command string """ + cmd = '' + ordered_cmds = sorted(self.cmds, key=lambda x: self.cmds[x]['_order']) + for style in ordered_cmds: + cmd += self.cmds[style][self.get(style)] + return cmd + +class XmlSerializer: + """ + Converts the xml inline / block tree structure to a string, + keeping track of newlines and spacings. + The string is outputted asap to the provided escpos driver. + """ + def __init__(self,escpos): + self.escpos = escpos + self.stack = ['block'] + self.dirty = False + + def start_inline(self,stylestack=None): + """ starts an inline entity with an optional style definition """ + self.stack.append('inline') + if self.dirty: + self.escpos._raw(' ') + if stylestack: + self.style(stylestack) + + def start_block(self,stylestack=None): + """ starts a block entity with an optional style definition """ + if self.dirty: + self.escpos._raw('\n') + self.dirty = False + self.stack.append('block') + if stylestack: + self.style(stylestack) + + def end_entity(self): + """ ends the entity definition. (but does not cancel the active style!) """ + if self.stack[-1] == 'block' and self.dirty: + self.escpos._raw('\n') + self.dirty = False + if len(self.stack) > 1: + self.stack = self.stack[:-1] + + def pre(self,text): + """ puts a string of text in the entity keeping the whitespace intact """ + if text: + self.escpos.text(text) + self.dirty = True + + def text(self,text): + """ puts text in the entity. Whitespace and newlines are stripped to single spaces. """ + if text: + text = utfstr(text) + text = text.strip() + text = re.sub(r'\s+', ' ', text) + if text: + self.dirty = True + self.escpos.text(text) + + def linebreak(self): + """ inserts a linebreak in the entity """ + self.dirty = False + self.escpos._raw('\n') + + def style(self,stylestack): + """ apply a style to the entity (only applies to content added after the definition) """ + self.raw(stylestack.to_escpos()) + + def raw(self,raw): + """ puts raw text or escpos command in the entity without affecting the state of the serializer """ + self.escpos._raw(raw) + +class XmlLineSerializer: + """ + This is used to convert a xml tree into a single line, with a left and a right part. + The content is not output to escpos directly, and is intended to be fedback to the + XmlSerializer as the content of a block entity. + """ + def __init__(self, indent=0, tabwidth=2, width=48, ratio=0.5): + self.tabwidth = tabwidth + self.indent = indent + self.width = max(0, width - int(tabwidth*indent)) + self.lwidth = int(self.width*ratio) + self.rwidth = max(0, self.width - self.lwidth) + self.clwidth = 0 + self.crwidth = 0 + self.lbuffer = '' + self.rbuffer = '' + self.left = True + + def _txt(self,txt): + if self.left: + if self.clwidth < self.lwidth: + txt = txt[:max(0, self.lwidth - self.clwidth)] + self.lbuffer += txt + self.clwidth += len(txt) + else: + if self.crwidth < self.rwidth: + txt = txt[:max(0, self.rwidth - self.crwidth)] + self.rbuffer += txt + self.crwidth += len(txt) + + def start_inline(self,stylestack=None): + if (self.left and self.clwidth) or (not self.left and self.crwidth): + self._txt(' ') + + def start_block(self,stylestack=None): + self.start_inline(stylestack) + + def end_entity(self): + pass + + def pre(self,text): + if text: + self._txt(text) + def text(self,text): + if text: + text = utfstr(text) + text = text.strip() + text = re.sub(r'\s+', ' ', text) + if text: + self._txt(text) + + def linebreak(self): + pass + def style(self,stylestack): + pass + def raw(self,raw): + pass + + def start_right(self): + self.left = False + + def get_line(self): + return ' ' * self.indent * self.tabwidth + self.lbuffer + ' ' * (self.width - self.clwidth - self.crwidth) + self.rbuffer + + +class Escpos: + """ ESC/POS Printer object """ + device = None + encoding = None + img_cache = {} + + def _check_image_size(self, size): + """ Check and fix the size of the image to 32 bits """ + if size % 32 == 0: + return (0, 0) + else: + image_border = 32 - (size % 32) + if (image_border % 2) == 0: + return (int(image_border / 2), int(image_border / 2)) + else: + return (int(image_border / 2), int((image_border / 2) + 1)) + + def _print_image(self, line, size): + """ Print formatted image """ + i = 0 + cont = 0 + buffer = "" + + + self._raw(S_RASTER_N) + buffer = b"%02X%02X%02X%02X" % (int((size[0]/size[1])/8), 0, size[1], 0) + self._raw(codecs.decode(buffer, 'hex')) + buffer = "" + + while i < len(line): + hex_string = int(line[i:i+8],2) + buffer += "%02X" % hex_string + i += 8 + cont += 1 + if cont % 4 == 0: + self._raw(codecs.decode(buffer, "hex")) + buffer = "" + cont = 0 + + def _raw_print_image(self, line, size, output=None ): + """ Print formatted image """ + i = 0 + cont = 0 + buffer = "" + raw = b"" + + def __raw(string): + if output: + output(string) + else: + self._raw(string) + + raw += S_RASTER_N.encode('utf-8') + buffer = "%02X%02X%02X%02X" % (int((size[0]/size[1])/8), 0, size[1], 0) + raw += codecs.decode(buffer, 'hex') + buffer = "" + + while i < len(line): + hex_string = int(line[i:i+8],2) + buffer += "%02X" % hex_string + i += 8 + cont += 1 + if cont % 4 == 0: + raw += codecs.decode(buffer, 'hex') + buffer = "" + cont = 0 + + return raw + + def _convert_image(self, im): + """ Parse image and prepare it to a printable format """ + pixels = [] + pix_line = "" + im_left = "" + im_right = "" + switch = 0 + img_size = [ 0, 0 ] + + + if im.size[0] > 512: + print("WARNING: Image is wider than 512 and could be truncated at print time ") + if im.size[1] > 255: + raise ImageSizeError() + + im_border = self._check_image_size(im.size[0]) + for i in range(im_border[0]): + im_left += "0" + for i in range(im_border[1]): + im_right += "0" + + for y in range(im.size[1]): + img_size[1] += 1 + pix_line += im_left + img_size[0] += im_border[0] + for x in range(im.size[0]): + img_size[0] += 1 + RGB = im.getpixel((x, y)) + im_color = (RGB[0] + RGB[1] + RGB[2]) + im_pattern = "1X0" + pattern_len = len(im_pattern) + switch = (switch - 1 ) * (-1) + for x in range(pattern_len): + if im_color <= (255 * 3 / pattern_len * (x+1)): + if im_pattern[x] == "X": + pix_line += "%d" % switch + else: + pix_line += im_pattern[x] + break + elif im_color > (255 * 3 / pattern_len * pattern_len) and im_color <= (255 * 3): + pix_line += im_pattern[-1] + break + pix_line += im_right + img_size[0] += im_border[1] + + return (pix_line, img_size) + + def image(self,path_img): + """ Open image file """ + im_open = Image.open(path_img) + im = im_open.convert("RGB") + # Convert the RGB image in printable image + pix_line, img_size = self._convert_image(im) + self._print_image(pix_line, img_size) + + def print_base64_image(self,img): + + print('print_b64_img') + + id = md5(img).digest() + + if id not in self.img_cache: + print('not in cache') + + img = img[img.find(b',')+1:] + f = io.BytesIO(b'img') + f.write(base64.decodebytes(img)) + f.seek(0) + img_rgba = Image.open(f) + img = Image.new('RGB', img_rgba.size, (255,255,255)) + channels = img_rgba.split() + if len(channels) > 3: + # use alpha channel as mask + img.paste(img_rgba, mask=channels[3]) + else: + img.paste(img_rgba) + + print('convert image') + + pix_line, img_size = self._convert_image(img) + + print('print image') + + buffer = self._raw_print_image(pix_line, img_size) + self.img_cache[id] = buffer + + print('raw image') + + self._raw(self.img_cache[id]) + + def qr(self,text): + """ Print QR Code for the provided string """ + qr_code = qrcode.QRCode(version=4, box_size=4, border=1) + qr_code.add_data(text) + qr_code.make(fit=True) + qr_img = qr_code.make_image() + im = qr_img._img.convert("RGB") + # Convert the RGB image in printable image + self._convert_image(im) + + def barcode(self, code, bc, width=255, height=2, pos='below', font='a'): + """ Print Barcode """ + # Align Bar Code() + self._raw(TXT_ALIGN_CT) + # Height + if height >=2 or height <=6: + self._raw(BARCODE_HEIGHT) + else: + raise BarcodeSizeError() + # Width + if width >= 1 or width <=255: + self._raw(BARCODE_WIDTH) + else: + raise BarcodeSizeError() + # Font + if font.upper() == "B": + self._raw(BARCODE_FONT_B) + else: # DEFAULT FONT: A + self._raw(BARCODE_FONT_A) + # Position + if pos.upper() == "OFF": + self._raw(BARCODE_TXT_OFF) + elif pos.upper() == "BOTH": + self._raw(BARCODE_TXT_BTH) + elif pos.upper() == "ABOVE": + self._raw(BARCODE_TXT_ABV) + else: # DEFAULT POSITION: BELOW + self._raw(BARCODE_TXT_BLW) + # Type + if bc.upper() == "UPC-A": + self._raw(BARCODE_UPC_A) + elif bc.upper() == "UPC-E": + self._raw(BARCODE_UPC_E) + elif bc.upper() == "EAN13": + self._raw(BARCODE_EAN13) + elif bc.upper() == "EAN8": + self._raw(BARCODE_EAN8) + elif bc.upper() == "CODE39": + self._raw(BARCODE_CODE39) + elif bc.upper() == "ITF": + self._raw(BARCODE_ITF) + elif bc.upper() == "NW7": + self._raw(BARCODE_NW7) + else: + raise BarcodeTypeError() + # Print Code + if code: + self._raw(code) + # We are using type A commands + # So we need to add the 'NULL' character + # https://github.com/python-escpos/python-escpos/pull/98/files#diff-a0b1df12c7c67e38915adbe469051e2dR444 + self._raw('\x00') + else: + raise BarcodeCodeError() + + def receipt(self,xml): + """ + Prints an xml based receipt definition + """ + + def strclean(string): + if not string: + string = '' + string = string.strip() + string = re.sub(r'\s+', ' ', string) + return string + + def format_value(value, decimals=3, width=0, decimals_separator='.', thousands_separator=',', autoint=False, symbol='', position='after'): + decimals = max(0,int(decimals)) + width = max(0,int(width)) + value = float(value) + + if autoint and math.floor(value) == value: + decimals = 0 + if width == 0: + width = '' + + if thousands_separator: + formatstr = "{:"+str(width)+",."+str(decimals)+"f}" + else: + formatstr = "{:"+str(width)+"."+str(decimals)+"f}" + + + ret = formatstr.format(value) + ret = ret.replace(',','COMMA') + ret = ret.replace('.','DOT') + ret = ret.replace('COMMA',thousands_separator) + ret = ret.replace('DOT',decimals_separator) + + if symbol: + if position == 'after': + ret = ret + symbol + else: + ret = symbol + ret + return ret + + def print_elem(stylestack, serializer, elem, indent=0): + + elem_styles = { + 'h1': {'bold': 'on', 'size':'double'}, + 'h2': {'size':'double'}, + 'h3': {'bold': 'on', 'size':'double-height'}, + 'h4': {'size': 'double-height'}, + 'h5': {'bold': 'on'}, + 'em': {'font': 'b'}, + 'b': {'bold': 'on'}, + } + + stylestack.push() + if elem.tag in elem_styles: + stylestack.set(elem_styles[elem.tag]) + stylestack.set(elem.attrib) + + if elem.tag in ('p','div','section','article','receipt','header','footer','li','h1','h2','h3','h4','h5'): + serializer.start_block(stylestack) + serializer.text(elem.text) + for child in elem: + print_elem(stylestack,serializer,child) + serializer.start_inline(stylestack) + serializer.text(child.tail) + serializer.end_entity() + serializer.end_entity() + + elif elem.tag in ('span','em','b','left','right'): + serializer.start_inline(stylestack) + serializer.text(elem.text) + for child in elem: + print_elem(stylestack,serializer,child) + serializer.start_inline(stylestack) + serializer.text(child.tail) + serializer.end_entity() + serializer.end_entity() + + elif elem.tag == 'value': + serializer.start_inline(stylestack) + serializer.pre(format_value( + elem.text, + decimals=stylestack.get('value-decimals'), + width=stylestack.get('value-width'), + decimals_separator=stylestack.get('value-decimals-separator'), + thousands_separator=stylestack.get('value-thousands-separator'), + autoint=(stylestack.get('value-autoint') == 'on'), + symbol=stylestack.get('value-symbol'), + position=stylestack.get('value-symbol-position') + )) + serializer.end_entity() + + elif elem.tag == 'line': + width = stylestack.get('width') + if stylestack.get('size') in ('double', 'double-width'): + width = width / 2 + + lineserializer = XmlLineSerializer(stylestack.get('indent')+indent,stylestack.get('tabwidth'),width,stylestack.get('line-ratio')) + serializer.start_block(stylestack) + for child in elem: + if child.tag == 'left': + print_elem(stylestack,lineserializer,child,indent=indent) + elif child.tag == 'right': + lineserializer.start_right() + print_elem(stylestack,lineserializer,child,indent=indent) + serializer.pre(lineserializer.get_line()) + serializer.end_entity() + + elif elem.tag == 'ul': + serializer.start_block(stylestack) + bullet = stylestack.get('bullet') + for child in elem: + if child.tag == 'li': + serializer.style(stylestack) + serializer.raw(' ' * indent * stylestack.get('tabwidth') + bullet) + print_elem(stylestack,serializer,child,indent=indent+1) + serializer.end_entity() + + elif elem.tag == 'ol': + cwidth = len(str(len(elem))) + 2 + i = 1 + serializer.start_block(stylestack) + for child in elem: + if child.tag == 'li': + serializer.style(stylestack) + serializer.raw(' ' * indent * stylestack.get('tabwidth') + ' ' + (str(i)+')').ljust(cwidth)) + i = i + 1 + print_elem(stylestack,serializer,child,indent=indent+1) + serializer.end_entity() + + elif elem.tag == 'pre': + serializer.start_block(stylestack) + serializer.pre(elem.text) + serializer.end_entity() + + elif elem.tag == 'hr': + width = stylestack.get('width') + if stylestack.get('size') in ('double', 'double-width'): + width = width / 2 + serializer.start_block(stylestack) + serializer.text('-'*width) + serializer.end_entity() + + elif elem.tag == 'br': + serializer.linebreak() + + elif elem.tag == 'img': + if 'src' in elem.attrib and 'data:' in elem.attrib['src']: + self.print_base64_image(bytes(elem.attrib['src'], 'utf-8')) + + elif elem.tag == 'barcode' and 'encoding' in elem.attrib: + serializer.start_block(stylestack) + self.barcode(strclean(elem.text),elem.attrib['encoding']) + serializer.end_entity() + + elif elem.tag == 'cut': + self.cut() + elif elem.tag == 'partialcut': + self.cut(mode='part') + elif elem.tag == 'cashdraw': + self.cashdraw(2) + self.cashdraw(5) + + stylestack.pop() + + try: + stylestack = StyleStack() + serializer = XmlSerializer(self) + root = ET.fromstring(xml.encode('utf-8')) + + self._raw(stylestack.to_escpos()) + + print_elem(stylestack,serializer,root) + + if 'open-cashdrawer' in root.attrib and root.attrib['open-cashdrawer'] == 'true': + self.cashdraw(2) + self.cashdraw(5) + if not 'cut' in root.attrib or root.attrib['cut'] == 'true' : + self.cut() + + except Exception as e: + errmsg = str(e)+'\n'+'-'*48+'\n'+traceback.format_exc() + '-'*48+'\n' + self.text(errmsg) + self.cut() + + raise e + + def text(self,txt): + """ Print Utf8 encoded alpha-numeric text """ + if not txt: + return + try: + txt = txt.decode('utf-8') + except: + try: + txt = txt.decode('utf-16') + except: + pass + + self.extra_chars = 0 + + def encode_char(char): + """ + Encodes a single utf-8 character into a sequence of + esc-pos code page change instructions and character declarations + """ + char_utf8 = char.encode('utf-8') + encoded = '' + encoding = self.encoding # we reuse the last encoding to prevent code page switches at every character + encodings = { + # TODO use ordering to prevent useless switches + # TODO Support other encodings not natively supported by python ( Thai, Khazakh, Kanjis ) + 'cp437': TXT_ENC_PC437, + 'cp850': TXT_ENC_PC850, + 'cp852': TXT_ENC_PC852, + 'cp857': TXT_ENC_PC857, + 'cp858': TXT_ENC_PC858, + 'cp860': TXT_ENC_PC860, + 'cp863': TXT_ENC_PC863, + 'cp865': TXT_ENC_PC865, + 'cp1251': TXT_ENC_WPC1251, # win-1251 covers more cyrillic symbols than cp866 + 'cp866': TXT_ENC_PC866, + 'cp862': TXT_ENC_PC862, + 'cp720': TXT_ENC_PC720, + 'cp936': TXT_ENC_PC936, + 'iso8859_2': TXT_ENC_8859_2, + 'iso8859_7': TXT_ENC_8859_7, + 'iso8859_9': TXT_ENC_8859_9, + 'cp1254' : TXT_ENC_WPC1254, + 'cp1255' : TXT_ENC_WPC1255, + 'cp1256' : TXT_ENC_WPC1256, + 'cp1257' : TXT_ENC_WPC1257, + 'cp1258' : TXT_ENC_WPC1258, + 'katakana' : TXT_ENC_KATAKANA, + } + remaining = copy.copy(encodings) + + if not encoding : + encoding = 'cp437' + + while True: # Trying all encoding until one succeeds + try: + if encoding == 'katakana': # Japanese characters + if jcconv: + # try to convert japanese text to a half-katakanas + kata = jcconv.kata2half(jcconv.hira2kata(char_utf8)) + if kata != char_utf8: + self.extra_chars += len(kata.decode('utf-8')) - 1 + # the conversion may result in multiple characters + return encode_str(kata.decode('utf-8')) + else: + kata = char_utf8 + + if kata in TXT_ENC_KATAKANA_MAP: + encoded = TXT_ENC_KATAKANA_MAP[kata] + break + else: + raise ValueError() + else: + # First 127 symbols are covered by cp437. + # Extended range is covered by different encodings. + encoded = char.encode(encoding) + if ord(encoded) <= 127: + encoding = 'cp437' + break + + except (UnicodeEncodeError, UnicodeWarning, TypeError, ValueError): + #the encoding failed, select another one and retry + if encoding in remaining: + del remaining[encoding] + if len(remaining) >= 1: + (encoding, _) = remaining.popitem() + else: + encoding = 'cp437' + encoded = b'\xb1' # could not encode, output error character + break + + if encoding != self.encoding: + # if the encoding changed, remember it and prefix the character with + # the esc-pos encoding change sequence + self.encoding = encoding + encoded = bytes(encodings[encoding], 'utf-8') + encoded + + return encoded + + def encode_str(txt): + buffer = b'' + for c in txt: + buffer += encode_char(c) + return buffer + + txt = encode_str(txt) + + # if the utf-8 -> codepage conversion inserted extra characters, + # remove double spaces to try to restore the original string length + # and prevent printing alignment issues + while self.extra_chars > 0: + dspace = txt.find(' ') + if dspace > 0: + txt = txt[:dspace] + txt[dspace+1:] + self.extra_chars -= 1 + else: + break + + self._raw(txt) + + def set(self, align='left', font='a', type='normal', width=1, height=1): + """ Set text properties """ + # Align + if align.upper() == "CENTER": + self._raw(TXT_ALIGN_CT) + elif align.upper() == "RIGHT": + self._raw(TXT_ALIGN_RT) + elif align.upper() == "LEFT": + self._raw(TXT_ALIGN_LT) + # Font + if font.upper() == "B": + self._raw(TXT_FONT_B) + else: # DEFAULT FONT: A + self._raw(TXT_FONT_A) + # Type + if type.upper() == "B": + self._raw(TXT_BOLD_ON) + self._raw(TXT_UNDERL_OFF) + elif type.upper() == "U": + self._raw(TXT_BOLD_OFF) + self._raw(TXT_UNDERL_ON) + elif type.upper() == "U2": + self._raw(TXT_BOLD_OFF) + self._raw(TXT_UNDERL2_ON) + elif type.upper() == "BU": + self._raw(TXT_BOLD_ON) + self._raw(TXT_UNDERL_ON) + elif type.upper() == "BU2": + self._raw(TXT_BOLD_ON) + self._raw(TXT_UNDERL2_ON) + elif type.upper == "NORMAL": + self._raw(TXT_BOLD_OFF) + self._raw(TXT_UNDERL_OFF) + # Width + if width == 2 and height != 2: + self._raw(TXT_NORMAL) + self._raw(TXT_2WIDTH) + elif height == 2 and width != 2: + self._raw(TXT_NORMAL) + self._raw(TXT_2HEIGHT) + elif height == 2 and width == 2: + self._raw(TXT_2WIDTH) + self._raw(TXT_2HEIGHT) + else: # DEFAULT SIZE: NORMAL + self._raw(TXT_NORMAL) + + + def cut(self, mode=''): + """ Cut paper """ + # Fix the size between last line and cut + # TODO: handle this with a line feed + self._raw("\n\n\n\n\n\n") + if mode.upper() == "PART": + self._raw(PAPER_PART_CUT) + else: # DEFAULT MODE: FULL CUT + self._raw(PAPER_FULL_CUT) + + + def cashdraw(self, pin): + """ Send pulse to kick the cash drawer + + For some reason, with some printers (ex: Epson TM-m30), the cash drawer + only opens 50% of the time if you just send the pulse. But if you read + the status afterwards, it opens all the time. + """ + if pin == 2: + self._raw(CD_KICK_2) + elif pin == 5: + self._raw(CD_KICK_5) + else: + raise CashDrawerError() + + self.get_printer_status() + + def hw(self, hw): + """ Hardware operations """ + if hw.upper() == "INIT": + self._raw(HW_INIT) + elif hw.upper() == "SELECT": + self._raw(HW_SELECT) + elif hw.upper() == "RESET": + self._raw(HW_RESET) + else: # DEFAULT: DOES NOTHING + pass + + + def control(self, ctl): + """ Feed control sequences """ + if ctl.upper() == "LF": + self._raw(CTL_LF) + elif ctl.upper() == "FF": + self._raw(CTL_FF) + elif ctl.upper() == "CR": + self._raw(CTL_CR) + elif ctl.upper() == "HT": + self._raw(CTL_HT) + elif ctl.upper() == "VT": + self._raw(CTL_VT) diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/escpos/exceptions.py b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/escpos/exceptions.py new file mode 100644 index 0000000..a56bd17 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/escpos/exceptions.py @@ -0,0 +1,115 @@ +""" ESC/POS Exceptions classes """ + + +class Error(Exception): + """ Base class for ESC/POS errors """ + def __init__(self, msg, status=None): + Exception.__init__(self) + self.msg = msg + self.resultcode = 1 + if status is not None: + self.resultcode = status + + def __str__(self): + return self.msg + +# Result/Exit codes +# 0 = success +# 10 = No Barcode type defined +# 20 = Barcode size values are out of range +# 30 = Barcode text not supplied +# 40 = Image height is too large +# 50 = No string supplied to be printed +# 60 = Invalid pin to send Cash Drawer pulse + + +class BarcodeTypeError(Error): + def __init__(self, msg=""): + Error.__init__(self, msg) + self.msg = msg + self.resultcode = 10 + + def __str__(self): + return "No Barcode type is defined" + +class BarcodeSizeError(Error): + def __init__(self, msg=""): + Error.__init__(self, msg) + self.msg = msg + self.resultcode = 20 + + def __str__(self): + return "Barcode size is out of range" + +class BarcodeCodeError(Error): + def __init__(self, msg=""): + Error.__init__(self, msg) + self.msg = msg + self.resultcode = 30 + + def __str__(self): + return "Code was not supplied" + +class ImageSizeError(Error): + def __init__(self, msg=""): + Error.__init__(self, msg) + self.msg = msg + self.resultcode = 40 + + def __str__(self): + return "Image height is longer than 255px and can't be printed" + +class TextError(Error): + def __init__(self, msg=""): + Error.__init__(self, msg) + self.msg = msg + self.resultcode = 50 + + def __str__(self): + return "Text string must be supplied to the text() method" + + +class CashDrawerError(Error): + def __init__(self, msg=""): + Error.__init__(self, msg) + self.msg = msg + self.resultcode = 60 + + def __str__(self): + return "Valid pin must be set to send pulse" + +class NoStatusError(Error): + def __init__(self, msg=""): + Error.__init__(self, msg) + self.msg = msg + self.resultcode = 70 + + def __str__(self): + return "Impossible to get status from the printer: " + str(self.msg) + +class TicketNotPrinted(Error): + def __init__(self, msg=""): + Error.__init__(self, msg) + self.msg = msg + self.resultcode = 80 + + def __str__(self): + return "A part of the ticket was not been printed: " + str(self.msg) + +class NoDeviceError(Error): + def __init__(self, msg=""): + Error.__init__(self, msg) + self.msg = msg + self.resultcode = 90 + + def __str__(self): + return str(self.msg) + +class HandleDeviceError(Error): + def __init__(self, msg=""): + Error.__init__(self, msg) + self.msg = msg + self.resultcode = 100 + + def __str__(self): + return str(self.msg) diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/escpos/printer.py b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/escpos/printer.py new file mode 100644 index 0000000..3aee55d --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/escpos/printer.py @@ -0,0 +1,227 @@ +#!/usr/bin/python + +from __future__ import print_function +import serial +import socket +import usb.core +import usb.util + +from .escpos import * +from .constants import * +from .exceptions import * +from time import sleep + +class Usb(Escpos): + """ Define USB printer """ + + def __init__(self, idVendor, idProduct, interface=0, in_ep=None, out_ep=None): + """ + @param idVendor : Vendor ID + @param idProduct : Product ID + @param interface : USB device interface + @param in_ep : Input end point + @param out_ep : Output end point + """ + + self.errorText = "ERROR PRINTER\n\n\n\n\n\n"+PAPER_FULL_CUT + + self.idVendor = idVendor + self.idProduct = idProduct + self.interface = interface + self.in_ep = in_ep + self.out_ep = out_ep + + # pyusb dropped the 'interface' parameter from usb.Device.write() at 1.0.0b2 + # https://github.com/pyusb/pyusb/commit/20cd8c1f79b24082ec999c022b56c3febedc0964#diff-b5a4f98a864952f0f55d569dd14695b7L293 + if usb.version_info < (1, 0, 0) or (usb.version_info == (1, 0, 0) and usb.version_info[3] in ("a1", "a2", "a3", "b1")): + self.write_kwargs = dict(interface=self.interface) + else: + self.write_kwargs = {} + + self.open() + + def open(self): + """ Search device on USB tree and set is as escpos device """ + + self.device = usb.core.find(idVendor=self.idVendor, idProduct=self.idProduct) + if self.device is None: + raise NoDeviceError() + try: + if self.device.is_kernel_driver_active(self.interface): + self.device.detach_kernel_driver(self.interface) + self.device.set_configuration() + usb.util.claim_interface(self.device, self.interface) + + cfg = self.device.get_active_configuration() + intf = cfg[(0,0)] # first interface + if self.in_ep is None: + # Attempt to detect IN/OUT endpoint addresses + try: + is_IN = lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_IN + is_OUT = lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_OUT + endpoint_in = usb.util.find_descriptor(intf, custom_match=is_IN) + endpoint_out = usb.util.find_descriptor(intf, custom_match=is_OUT) + self.in_ep = endpoint_in.bEndpointAddress + self.out_ep = endpoint_out.bEndpointAddress + except usb.core.USBError: + # default values for officially supported printers + self.in_ep = 0x82 + self.out_ep = 0x01 + + except usb.core.USBError as e: + raise HandleDeviceError(e) + + def close(self): + i = 0 + while True: + try: + if not self.device.is_kernel_driver_active(self.interface): + usb.util.release_interface(self.device, self.interface) + self.device.attach_kernel_driver(self.interface) + usb.util.dispose_resources(self.device) + else: + self.device = None + return True + except usb.core.USBError as e: + i += 1 + if i > 10: + return False + + sleep(0.1) + + def _raw(self, msg): + """ Print any command sent in raw format """ + if len(msg) != self.device.write(self.out_ep, msg, timeout=5000, **self.write_kwargs): + self.device.write(self.out_ep, self.errorText, **self.write_kwargs) + raise TicketNotPrinted() + + def __extract_status(self): + maxiterate = 0 + rep = None + while rep == None: + maxiterate += 1 + if maxiterate > 10000: + raise NoStatusError() + r = self.device.read(self.in_ep, 20, self.interface).tolist() + while len(r): + rep = r.pop() + return rep + + def get_printer_status(self): + status = { + 'printer': {}, + 'offline': {}, + 'error' : {}, + 'paper' : {}, + } + + self.device.write(self.out_ep, DLE_EOT_PRINTER, **self.write_kwargs) + printer = self.__extract_status() + self.device.write(self.out_ep, DLE_EOT_OFFLINE, **self.write_kwargs) + offline = self.__extract_status() + self.device.write(self.out_ep, DLE_EOT_ERROR, **self.write_kwargs) + error = self.__extract_status() + self.device.write(self.out_ep, DLE_EOT_PAPER, **self.write_kwargs) + paper = self.__extract_status() + + status['printer']['status_code'] = printer + status['printer']['status_error'] = not ((printer & 147) == 18) + status['printer']['online'] = not bool(printer & 8) + status['printer']['recovery'] = bool(printer & 32) + status['printer']['paper_feed_on'] = bool(printer & 64) + status['printer']['drawer_pin_high'] = bool(printer & 4) + status['offline']['status_code'] = offline + status['offline']['status_error'] = not ((offline & 147) == 18) + status['offline']['cover_open'] = bool(offline & 4) + status['offline']['paper_feed_on'] = bool(offline & 8) + status['offline']['paper'] = not bool(offline & 32) + status['offline']['error'] = bool(offline & 64) + status['error']['status_code'] = error + status['error']['status_error'] = not ((error & 147) == 18) + status['error']['recoverable'] = bool(error & 4) + status['error']['autocutter'] = bool(error & 8) + status['error']['unrecoverable'] = bool(error & 32) + status['error']['auto_recoverable'] = not bool(error & 64) + status['paper']['status_code'] = paper + status['paper']['status_error'] = not ((paper & 147) == 18) + status['paper']['near_end'] = bool(paper & 12) + status['paper']['present'] = not bool(paper & 96) + + return status + + def __del__(self): + """ Release USB interface """ + if self.device: + self.close() + self.device = None + + + +class Serial(Escpos): + """ Define Serial printer """ + + def __init__(self, devfile="/dev/ttyS0", baudrate=9600, bytesize=8, timeout=1): + """ + @param devfile : Device file under dev filesystem + @param baudrate : Baud rate for serial transmission + @param bytesize : Serial buffer size + @param timeout : Read/Write timeout + """ + self.devfile = devfile + self.baudrate = baudrate + self.bytesize = bytesize + self.timeout = timeout + self.open() + + + def open(self): + """ Setup serial port and set is as escpos device """ + self.device = serial.Serial(port=self.devfile, baudrate=self.baudrate, bytesize=self.bytesize, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, timeout=self.timeout, dsrdtr=True) + + if self.device is not None: + print("Serial printer enabled") + else: + print("Unable to open serial printer on: %s" % self.devfile) + + + def _raw(self, msg): + """ Print any command sent in raw format """ + self.device.write(msg) + + + def __del__(self): + """ Close Serial interface """ + if self.device is not None: + self.device.close() + + + +class Network(Escpos): + """ Define Network printer """ + + def __init__(self,host,port=9100): + """ + @param host : Printer's hostname or IP address + @param port : Port to write to + """ + self.host = host + self.port = port + self.open() + + + def open(self): + """ Open TCP socket and set it as escpos device """ + self.device = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.device.connect((self.host, self.port)) + + if self.device is None: + print("Could not open socket for %s" % self.host) + + + def _raw(self, msg): + self.device.send(msg) + + + def __del__(self): + """ Close TCP connection """ + self.device.close() diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/af.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/af.po new file mode 100644 index 0000000..efc5204 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/af.po @@ -0,0 +1,43 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-08-25 10:14+0000\n" +"Last-Translator: <>\n" +"Language-Team: Afrikaans (http://www.transifex.com/odoo/odoo-9/language/" +"af/)\n" +"Language: af\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Subtotaal" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/ar.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/ar.po new file mode 100644 index 0000000..e0d3302 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/ar.po @@ -0,0 +1,43 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-09-07 21:35+0000\n" +"Last-Translator: Martin Trigaux\n" +"Language-Team: Arabic (http://www.transifex.com/odoo/odoo-9/language/ar/)\n" +"Language: ar\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 " +"&& n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "الخصومات" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "المجموع" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/bg.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/bg.po new file mode 100644 index 0000000..6c2ff41 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/bg.po @@ -0,0 +1,43 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-09-07 21:35+0000\n" +"Last-Translator: Martin Trigaux\n" +"Language-Team: Bulgarian (http://www.transifex.com/odoo/odoo-9/language/" +"bg/)\n" +"Language: bg\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "Отстъпки" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Междинна сума" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/bs.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/bs.po new file mode 100644 index 0000000..031a55d --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/bs.po @@ -0,0 +1,41 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-09-07 14:39+0000\n" +"Last-Translator: <>\n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr " UKUPNO" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr " PROMJENA" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "Popusti" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Sub-ukupno" + diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/ca.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/ca.po new file mode 100644 index 0000000..c39bcf4 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/ca.po @@ -0,0 +1,42 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-09-07 21:35+0000\n" +"Last-Translator: Martin Trigaux\n" +"Language-Team: Catalan (http://www.transifex.com/odoo/odoo-9/language/ca/)\n" +"Language: ca\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "Descomptes" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Subtotal" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/cs.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/cs.po new file mode 100644 index 0000000..cd424a6 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/cs.po @@ -0,0 +1,43 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +# Chris , 2016 +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2016-06-02 14:32+0000\n" +"Last-Translator: Chris \n" +"Language-Team: Czech (http://www.transifex.com/odoo/odoo-9/language/cs/)\n" +"Language: cs\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "CELKEM" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "Slevy" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Mezisoučet" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/da.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/da.po new file mode 100644 index 0000000..580af78 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/da.po @@ -0,0 +1,42 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-09-07 21:35+0000\n" +"Last-Translator: Martin Trigaux\n" +"Language-Team: Danish (http://www.transifex.com/odoo/odoo-9/language/da/)\n" +"Language: da\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Subtotal" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/de.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/de.po new file mode 100644 index 0000000..e65f89d --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/de.po @@ -0,0 +1,44 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +# Fabian Liesch , 2015 +# Wolfgang Taferner, 2016 +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2016-01-18 06:57+0000\n" +"Last-Translator: Wolfgang Taferner\n" +"Language-Team: German (http://www.transifex.com/odoo/odoo-9/language/de/)\n" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "GESAMTSUMME" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "WECHSELGELD" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "Rabatte" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Zwischensumme" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/el.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/el.po new file mode 100644 index 0000000..fa31379 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/el.po @@ -0,0 +1,43 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +# Goutoudis Kostas , 2015 +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-11-20 16:47+0000\n" +"Last-Translator: Goutoudis Kostas \n" +"Language-Team: Greek (http://www.transifex.com/odoo/odoo-9/language/el/)\n" +"Language: el\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "ΣΥΝΟΛΟ" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "Eκπτώσεις" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Μερικό σύνολο" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/en_GB.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/en_GB.po new file mode 100644 index 0000000..e4f1b6c --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/en_GB.po @@ -0,0 +1,43 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-09-07 21:36+0000\n" +"Last-Translator: Martin Trigaux\n" +"Language-Team: English (United Kingdom) (http://www.transifex.com/odoo/" +"odoo-9/language/en_GB/)\n" +"Language: en_GB\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Subtotal" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es.po new file mode 100644 index 0000000..2733279 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es.po @@ -0,0 +1,44 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +# Carlos rodriguez , 2016 +# Oihane Crucelaegui , 2015 +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2016-01-03 20:30+0000\n" +"Last-Translator: Carlos rodriguez \n" +"Language-Team: Spanish (http://www.transifex.com/odoo/odoo-9/language/es/)\n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr " TOTAL" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "Cambio" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "Descuentos" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Subtotal" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_BO.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_BO.po new file mode 100644 index 0000000..2f74907 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_BO.po @@ -0,0 +1,43 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-08-25 10:14+0000\n" +"Last-Translator: <>\n" +"Language-Team: Spanish (Bolivia) (http://www.transifex.com/odoo/odoo-9/" +"language/es_BO/)\n" +"Language: es_BO\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "Descuentos" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Subtotal" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_CL.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_CL.po new file mode 100644 index 0000000..0dc9edf --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_CL.po @@ -0,0 +1,43 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-09-07 21:37+0000\n" +"Last-Translator: Martin Trigaux\n" +"Language-Team: Spanish (Chile) (http://www.transifex.com/odoo/odoo-9/" +"language/es_CL/)\n" +"Language: es_CL\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Subtotal" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_CO.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_CO.po new file mode 100644 index 0000000..ce50a7d --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_CO.po @@ -0,0 +1,44 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +# Mateo Tibaquirá , 2015 +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-10-24 06:51+0000\n" +"Last-Translator: Mateo Tibaquirá \n" +"Language-Team: Spanish (Colombia) (http://www.transifex.com/odoo/odoo-9/" +"language/es_CO/)\n" +"Language: es_CO\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "TOTAL" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "CHANGE" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "Descuentos" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Subtotal" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_CR.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_CR.po new file mode 100644 index 0000000..576b75b --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_CR.po @@ -0,0 +1,43 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-09-07 21:37+0000\n" +"Last-Translator: Martin Trigaux\n" +"Language-Team: Spanish (Costa Rica) (http://www.transifex.com/odoo/odoo-9/" +"language/es_CR/)\n" +"Language: es_CR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Subtotal" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_DO.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_DO.po new file mode 100644 index 0000000..4720184 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_DO.po @@ -0,0 +1,44 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +# Juliano Henriquez , 2016 +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2016-05-19 05:09+0000\n" +"Last-Translator: Juliano Henriquez \n" +"Language-Team: Spanish (Dominican Republic) (http://www.transifex.com/odoo/" +"odoo-9/language/es_DO/)\n" +"Language: es_DO\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "TOTAL" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "CAMBIO" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "Descuentos" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Subtotal" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_EC.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_EC.po new file mode 100644 index 0000000..63ef062 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_EC.po @@ -0,0 +1,44 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +# Rick Hunter , 2015 +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-12-07 04:23+0000\n" +"Last-Translator: Rick Hunter \n" +"Language-Team: Spanish (Ecuador) (http://www.transifex.com/odoo/odoo-9/" +"language/es_EC/)\n" +"Language: es_EC\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "TOTAL" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "CAMBIO" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "Descuentos" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Subtotal" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_PE.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_PE.po new file mode 100644 index 0000000..d19ff46 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_PE.po @@ -0,0 +1,44 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +# Carlos Eduardo Rodriguez Rossi , 2016 +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2016-06-13 15:49+0000\n" +"Last-Translator: Carlos Eduardo Rodriguez Rossi \n" +"Language-Team: Spanish (Peru) (http://www.transifex.com/odoo/odoo-9/language/" +"es_PE/)\n" +"Language: es_PE\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "TOTAL" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "VUELTO" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "Descuentos" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Subtotal" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_PY.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_PY.po new file mode 100644 index 0000000..9f657e0 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_PY.po @@ -0,0 +1,43 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-09-07 21:38+0000\n" +"Last-Translator: Martin Trigaux\n" +"Language-Team: Spanish (Paraguay) (http://www.transifex.com/odoo/odoo-9/" +"language/es_PY/)\n" +"Language: es_PY\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Sub-Total" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_VE.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_VE.po new file mode 100644 index 0000000..c33ad8c --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/es_VE.po @@ -0,0 +1,43 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-09-07 21:38+0000\n" +"Last-Translator: Martin Trigaux\n" +"Language-Team: Spanish (Venezuela) (http://www.transifex.com/odoo/odoo-9/" +"language/es_VE/)\n" +"Language: es_VE\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Subtotal" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/et.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/et.po new file mode 100644 index 0000000..3a9cb2f --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/et.po @@ -0,0 +1,42 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-09-07 21:36+0000\n" +"Last-Translator: Martin Trigaux\n" +"Language-Team: Estonian (http://www.transifex.com/odoo/odoo-9/language/et/)\n" +"Language: et\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "Soodustused" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Vahesumma" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/eu.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/eu.po new file mode 100644 index 0000000..d5e22d4 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/eu.po @@ -0,0 +1,42 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-10-21 11:17+0000\n" +"Last-Translator: Martin Trigaux\n" +"Language-Team: Basque (http://www.transifex.com/odoo/odoo-9/language/eu/)\n" +"Language: eu\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "Deskontuak" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Batura partziala" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/fa.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/fa.po new file mode 100644 index 0000000..345c2a9 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/fa.po @@ -0,0 +1,42 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-09-07 21:37+0000\n" +"Last-Translator: Martin Trigaux\n" +"Language-Team: Persian (http://www.transifex.com/odoo/odoo-9/language/fa/)\n" +"Language: fa\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "جمع جزء" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/fi.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/fi.po new file mode 100644 index 0000000..f3d2e5d --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/fi.po @@ -0,0 +1,43 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +# Tuomo Aura , 2016 +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2016-04-19 15:31+0000\n" +"Last-Translator: Tuomo Aura \n" +"Language-Team: Finnish (http://www.transifex.com/odoo/odoo-9/language/fi/)\n" +"Language: fi\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "YHTEENSÄ" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "TAKAISIN" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "Alennukset" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Välisumma" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/fr.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/fr.po new file mode 100644 index 0000000..7fa051c --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/fr.po @@ -0,0 +1,45 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +# Lucas Deliege , 2015 +# Maxime Chambreuil , 2015 +# Sylvain GROS-DESORMEAUX , 2015 +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-10-01 23:16+0000\n" +"Last-Translator: Maxime Chambreuil \n" +"Language-Team: French (http://www.transifex.com/odoo/odoo-9/language/fr/)\n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "TOTAL" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "MONNAIE" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "Remises" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Sous-total" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/gu.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/gu.po new file mode 100644 index 0000000..8fed0da --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/gu.po @@ -0,0 +1,43 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +# Ranjit Pillai , 2015 +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-09-07 21:36+0000\n" +"Last-Translator: Martin Trigaux\n" +"Language-Team: Gujarati (http://www.transifex.com/odoo/odoo-9/language/gu/)\n" +"Language: gu\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "કુલ" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "ફેરફાર" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "ડિસ્કાઉન્ટ" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/he.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/he.po new file mode 100644 index 0000000..05ea1ed --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/he.po @@ -0,0 +1,56 @@ +# #-#-#-#-# he.po (Odoo 9.0) #-#-#-#-# +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +# Moshe Fam , 2016 +# yizhaq agronov , 2016 +# #-#-#-#-# he.po (Odoo 9.0) #-#-#-#-# +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +# yizhaq agronov , 2016 +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2016-02-27 04:30+0000\n" +"Last-Translator: yizhaq agronov \n" +"Language-Team: Hebrew (http://www.transifex.com/odoo/odoo-9/language/he/)\n" +"Language: he\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"#-#-#-#-# he.po (Odoo 9.0) #-#-#-#-#\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"#-#-#-#-# he.po (Odoo 9.0) #-#-#-#-#\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "סה\"כ" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "מזומן" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "הנחות" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "סיכום ביניים" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/hi.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/hi.po new file mode 100644 index 0000000..4846b78 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/hi.po @@ -0,0 +1,42 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-09-07 21:36+0000\n" +"Last-Translator: Martin Trigaux\n" +"Language-Team: Hindi (http://www.transifex.com/odoo/odoo-9/language/hi/)\n" +"Language: hi\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "उप कुल" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/hr.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/hr.po new file mode 100644 index 0000000..0e20457 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/hr.po @@ -0,0 +1,43 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-09-07 21:35+0000\n" +"Last-Translator: Martin Trigaux\n" +"Language-Team: Croatian (http://www.transifex.com/odoo/odoo-9/language/hr/)\n" +"Language: hr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n" +"%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "Popusti" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Podzbroj" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/hu.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/hu.po new file mode 100644 index 0000000..5b2e060 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/hu.po @@ -0,0 +1,44 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +# Kris Krnacs, 2015 +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-11-11 22:25+0000\n" +"Last-Translator: Kris Krnacs\n" +"Language-Team: Hungarian (http://www.transifex.com/odoo/odoo-9/language/" +"hu/)\n" +"Language: hu\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr " ÖSSZESEN" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr " VISSZAJÁRÓ" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "Árengedmények" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Nettó érték" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/hw_escpos.pot b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/hw_escpos.pot new file mode 100644 index 0000000..6143656 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/hw_escpos.pot @@ -0,0 +1,41 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-09-07 14:39+0000\n" +"Last-Translator: <>\n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "" + diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/id.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/id.po new file mode 100644 index 0000000..c3f0f67 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/id.po @@ -0,0 +1,43 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-09-07 21:36+0000\n" +"Last-Translator: Martin Trigaux\n" +"Language-Team: Indonesian (http://www.transifex.com/odoo/odoo-9/language/" +"id/)\n" +"Language: id\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "Diskon" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Sub Total" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/it.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/it.po new file mode 100644 index 0000000..ab7da93 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/it.po @@ -0,0 +1,42 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-09-07 21:36+0000\n" +"Last-Translator: Martin Trigaux\n" +"Language-Team: Italian (http://www.transifex.com/odoo/odoo-9/language/it/)\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "Sconti" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Subtotale" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/ja.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/ja.po new file mode 100644 index 0000000..a65f0d8 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/ja.po @@ -0,0 +1,43 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +# Yoshi Tashiro , 2016 +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2016-07-14 08:45+0000\n" +"Last-Translator: Yoshi Tashiro \n" +"Language-Team: Japanese (http://www.transifex.com/odoo/odoo-9/language/ja/)\n" +"Language: ja\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "値引" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "小計" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/kab.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/kab.po new file mode 100644 index 0000000..d614439 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/kab.po @@ -0,0 +1,42 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-09-07 21:36+0000\n" +"Last-Translator: Martin Trigaux\n" +"Language-Team: Kabyle (http://www.transifex.com/odoo/odoo-9/language/kab/)\n" +"Language: kab\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "Tuǧǧiyin" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Ad-asemday" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/ko.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/ko.po new file mode 100644 index 0000000..a153a8a --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/ko.po @@ -0,0 +1,42 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-09-07 21:36+0000\n" +"Last-Translator: Martin Trigaux\n" +"Language-Team: Korean (http://www.transifex.com/odoo/odoo-9/language/ko/)\n" +"Language: ko\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "할인" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "소계" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/lt.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/lt.po new file mode 100644 index 0000000..54ed5b1 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/lt.po @@ -0,0 +1,44 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-09-07 21:37+0000\n" +"Last-Translator: Martin Trigaux\n" +"Language-Team: Lithuanian (http://www.transifex.com/odoo/odoo-9/language/" +"lt/)\n" +"Language: lt\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && (n" +"%100<10 || n%100>=20) ? 1 : 2);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "Nuolaidos" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Suma" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/lv.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/lv.po new file mode 100644 index 0000000..d4bacd2 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/lv.po @@ -0,0 +1,43 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-09-07 21:36+0000\n" +"Last-Translator: Martin Trigaux\n" +"Language-Team: Latvian (http://www.transifex.com/odoo/odoo-9/language/lv/)\n" +"Language: lv\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n != 0 ? 1 : " +"2);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Apakšsumma kopā" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/mk.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/mk.po new file mode 100644 index 0000000..d24e842 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/mk.po @@ -0,0 +1,44 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +# Aleksandar Vangelovski , 2016 +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2016-06-10 12:29+0000\n" +"Last-Translator: Aleksandar Vangelovski \n" +"Language-Team: Macedonian (http://www.transifex.com/odoo/odoo-9/language/" +"mk/)\n" +"Language: mk\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n % 10 == 1 && n % 100 != 11) ? 0 : 1;\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "ВКУПНО" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "Попусти" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Вкупно" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/mn.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/mn.po new file mode 100644 index 0000000..16cf60c --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/mn.po @@ -0,0 +1,43 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-09-07 21:37+0000\n" +"Last-Translator: Martin Trigaux\n" +"Language-Team: Mongolian (http://www.transifex.com/odoo/odoo-9/language/" +"mn/)\n" +"Language: mn\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Дэд дүн" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/nb.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/nb.po new file mode 100644 index 0000000..1cec269 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/nb.po @@ -0,0 +1,43 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-09-07 21:37+0000\n" +"Last-Translator: Martin Trigaux\n" +"Language-Team: Norwegian Bokmål (http://www.transifex.com/odoo/odoo-9/" +"language/nb/)\n" +"Language: nb\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Subtotal" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/nl.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/nl.po new file mode 100644 index 0000000..a3fee47 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/nl.po @@ -0,0 +1,43 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +# Yenthe Van Ginneken , 2015 +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-09-10 07:35+0000\n" +"Last-Translator: Yenthe Van Ginneken \n" +"Language-Team: Dutch (http://www.transifex.com/odoo/odoo-9/language/nl/)\n" +"Language: nl\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "TOTAAL" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "WISSELGELD" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "Kortingen" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Subtotaal" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/pl.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/pl.po new file mode 100644 index 0000000..e3e4f20 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/pl.po @@ -0,0 +1,44 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +# Piotr Szlązak , 2016 +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2016-06-09 07:31+0000\n" +"Last-Translator: Piotr Szlązak \n" +"Language-Team: Polish (http://www.transifex.com/odoo/odoo-9/language/pl/)\n" +"Language: pl\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 " +"|| n%100>=20) ? 1 : 2);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "SUMA" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "ZMIANA" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "Upusty" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Suma częściowa" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/pt.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/pt.po new file mode 100644 index 0000000..3d8b856 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/pt.po @@ -0,0 +1,55 @@ +# #-#-#-#-# pt.po (Odoo 9.0) #-#-#-#-# +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +# Dawilson Daio , 2016 +# #-#-#-#-# pt.po (Odoo 9.0) #-#-#-#-# +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-09-07 21:37+0000\n" +"Last-Translator: Martin Trigaux\n" +"Language-Team: Portuguese (http://www.transifex.com/odoo/odoo-9/language/" +"pt/)\n" +"Language: pt\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"#-#-#-#-# pt.po (Odoo 9.0) #-#-#-#-#\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"#-#-#-#-# pt.po (Odoo 9.0) #-#-#-#-#\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "TOTAL" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "TROCO" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "Descontos:" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Subtotal" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/pt_BR.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/pt_BR.po new file mode 100644 index 0000000..50b652e --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/pt_BR.po @@ -0,0 +1,44 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +# Grazziano Duarte , 2015 +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-12-08 15:29+0000\n" +"Last-Translator: Grazziano Duarte \n" +"Language-Team: Portuguese (Brazil) (http://www.transifex.com/odoo/odoo-9/" +"language/pt_BR/)\n" +"Language: pt_BR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr " TOTAL" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "ALTERAR" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "Descontos" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Subtotal" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/ro.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/ro.po new file mode 100644 index 0000000..0a99bcf --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/ro.po @@ -0,0 +1,44 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +# Sorin Trifu , 2015 +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-10-20 07:19+0000\n" +"Last-Translator: Sorin Trifu \n" +"Language-Team: Romanian (http://www.transifex.com/odoo/odoo-9/language/ro/)\n" +"Language: ro\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=3; plural=(n==1?0:(((n%100>19)||((n%100==0)&&(n!=0)))?" +"2:1));\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "TOTAL" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "REST" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "Reduceri:" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Subtotal" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/ru.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/ru.po new file mode 100644 index 0000000..91b31db --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/ru.po @@ -0,0 +1,45 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +# Gennady Marchenko , 2016 +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2016-01-15 19:41+0000\n" +"Last-Translator: Gennady Marchenko \n" +"Language-Team: Russian (http://www.transifex.com/odoo/odoo-9/language/ru/)\n" +"Language: ru\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n" +"%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n" +"%100>=11 && n%100<=14)? 2 : 3);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "ИТОГО" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "Скидки" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Подитог" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/sk.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/sk.po new file mode 100644 index 0000000..026f091 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/sk.po @@ -0,0 +1,43 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +# Jaroslav Bosansky , 2016 +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2016-01-29 22:54+0000\n" +"Last-Translator: Pavol Krnáč \n" +"Language-Team: Slovak (http://www.transifex.com/odoo/odoo-9/language/sk/)\n" +"Language: sk\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "CELKOVO" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "VÝDAVOK" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "Zľavy" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Medzisúčet" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/sl.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/sl.po new file mode 100644 index 0000000..763b933 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/sl.po @@ -0,0 +1,44 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-09-07 21:37+0000\n" +"Last-Translator: Martin Trigaux\n" +"Language-Team: Slovenian (http://www.transifex.com/odoo/odoo-9/language/" +"sl/)\n" +"Language: sl\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=4; plural=(n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n" +"%100==4 ? 2 : 3);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "Popusti" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Skupaj" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/sq.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/sq.po new file mode 100644 index 0000000..2919ed3 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/sq.po @@ -0,0 +1,42 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-08-25 10:14+0000\n" +"Last-Translator: <>\n" +"Language-Team: Albanian (http://www.transifex.com/odoo/odoo-9/language/sq/)\n" +"Language: sq\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "Zbritjet" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/sr.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/sr.po new file mode 100644 index 0000000..3f938ba --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/sr.po @@ -0,0 +1,43 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-09-07 21:37+0000\n" +"Last-Translator: Martin Trigaux\n" +"Language-Team: Serbian (http://www.transifex.com/odoo/odoo-9/language/sr/)\n" +"Language: sr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n" +"%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Ukupno" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/sr@latin.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/sr@latin.po new file mode 100644 index 0000000..c6b2f74 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/sr@latin.po @@ -0,0 +1,44 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-09-07 21:37+0000\n" +"Last-Translator: Martin Trigaux\n" +"Language-Team: Serbian (Latin) (http://www.transifex.com/odoo/odoo-9/" +"language/sr@latin/)\n" +"Language: sr@latin\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n" +"%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Ukupno" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/sv.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/sv.po new file mode 100644 index 0000000..b994ad1 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/sv.po @@ -0,0 +1,43 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +# Kristoffer Grundström , 2015 +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-09-07 21:38+0000\n" +"Last-Translator: Martin Trigaux\n" +"Language-Team: Swedish (http://www.transifex.com/odoo/odoo-9/language/sv/)\n" +"Language: sv\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "TOTAL" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "FÖRÄNDRING" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Deltotal" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/th.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/th.po new file mode 100644 index 0000000..c40ab55 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/th.po @@ -0,0 +1,43 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +# Khwunchai Jaengsawang , 2016 +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2016-03-30 21:40+0000\n" +"Last-Translator: Khwunchai Jaengsawang \n" +"Language-Team: Thai (http://www.transifex.com/odoo/odoo-9/language/th/)\n" +"Language: th\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "ทั้งหมด" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "เปลี่ยนแปลง" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "ลดราคา" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "รวม" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/tr.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/tr.po new file mode 100644 index 0000000..4a4cb63 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/tr.po @@ -0,0 +1,43 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +# Ramiz Deniz Öner , 2016 +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2016-03-01 11:32+0000\n" +"Last-Translator: Ramiz Deniz Öner \n" +"Language-Team: Turkish (http://www.transifex.com/odoo/odoo-9/language/tr/)\n" +"Language: tr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "TOPLAM" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "DEĞİŞTİR" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "İndirimler" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Ara Toplam" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/uk.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/uk.po new file mode 100644 index 0000000..5115cef --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/uk.po @@ -0,0 +1,46 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +# Bohdan Lisnenko, 2015 +# Bohdan Lisnenko, 2016 +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2016-04-27 16:53+0000\n" +"Last-Translator: Bohdan Lisnenko\n" +"Language-Team: Ukrainian (http://www.transifex.com/odoo/odoo-9/language/" +"uk/)\n" +"Language: uk\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n" +"%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "РАЗОМ" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "Знижки" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Підсумок" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/vi.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/vi.po new file mode 100644 index 0000000..8068c90 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/vi.po @@ -0,0 +1,43 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-09-07 21:38+0000\n" +"Last-Translator: Martin Trigaux\n" +"Language-Team: Vietnamese (http://www.transifex.com/odoo/odoo-9/language/" +"vi/)\n" +"Language: vi\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "Tổng phụ" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/zh_CN.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/zh_CN.po new file mode 100644 index 0000000..b206292 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/zh_CN.po @@ -0,0 +1,46 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +# fausthuang, 2016 +# jeffery9, 2015 +# 珠海-老天 , 2015 +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2016-01-11 20:15+0000\n" +"Last-Translator: fausthuang\n" +"Language-Team: Chinese (China) (http://www.transifex.com/odoo/odoo-9/" +"language/zh_CN/)\n" +"Language: zh_CN\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "总计" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "找零" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "折扣" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "小计" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/zh_HK.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/zh_HK.po new file mode 100644 index 0000000..bcbcfe3 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/zh_HK.po @@ -0,0 +1,43 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-09-07 21:35+0000\n" +"Last-Translator: Martin Trigaux\n" +"Language-Team: Chinese (Hong Kong) (http://www.transifex.com/odoo/odoo-9/" +"language/zh_HK/)\n" +"Language: zh_HK\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "小計" diff --git a/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/zh_TW.po b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/zh_TW.po new file mode 100644 index 0000000..11d1f2d --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/hw_escpos/i18n/zh_TW.po @@ -0,0 +1,43 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hw_escpos +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: Odoo 9.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-09-07 14:39+0000\n" +"PO-Revision-Date: 2015-09-07 21:35+0000\n" +"Last-Translator: Martin Trigaux\n" +"Language-Team: Chinese (Taiwan) (http://www.transifex.com/odoo/odoo-9/" +"language/zh_TW/)\n" +"Language: zh_TW\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:302 +#, python-format +msgid " TOTAL" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:312 +#, python-format +msgid " CHANGE" +msgstr "" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:318 +#, python-format +msgid "Discounts" +msgstr "折扣" + +#. module: hw_escpos +#: code:addons/hw_escpos/controllers/main.py:293 +#, python-format +msgid "Subtotal" +msgstr "小計" diff --git a/odoo-bringout-oca-ocb-hw_escpos/pyproject.toml b/odoo-bringout-oca-ocb-hw_escpos/pyproject.toml new file mode 100644 index 0000000..15ae87e --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_escpos/pyproject.toml @@ -0,0 +1,41 @@ +[project] +name = "odoo-bringout-oca-ocb-hw_escpos" +version = "16.0.0" +description = "ESC/POS Hardware Driver - Hardware Driver for ESC/POS Printers and Cashdrawers" +authors = [ + { name = "Ernad Husremovic", email = "hernad@bring.out.ba" } +] +dependencies = [ + "requests>=2.25.1" +] +readme = "README.md" +requires-python = ">= 3.11" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Office/Business", +] + +[project.urls] +homepage = "https://github.com/bringout/0" +repository = "https://github.com/bringout/0" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.build.targets.wheel] +packages = ["hw_escpos"] + +[tool.rye] +managed = true +dev-dependencies = [ + "pytest>=8.4.1", +] diff --git a/odoo-bringout-oca-ocb-hw_posbox_homepage/README.md b/odoo-bringout-oca-ocb-hw_posbox_homepage/README.md new file mode 100644 index 0000000..4b3b502 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_posbox_homepage/README.md @@ -0,0 +1,55 @@ +# IoT Box Homepage + + +IoT Box Homepage +================ + +This module overrides Odoo web interface to display a simple +Homepage that explains what's the iotbox and shows the status, +and where to find documentation. + +If you activate this module, you won't be able to access the +regular Odoo interface anymore. + + + +## Installation + +```bash +pip install odoo-bringout-oca-ocb-hw_posbox_homepage +``` + +## Dependencies + +This addon depends on: + + +## Manifest Information + +- **Name**: IoT Box Homepage +- **Version**: N/A +- **Category**: Sales/Point of Sale +- **License**: LGPL-3 +- **Installable**: False + +## Source + +Based on [OCA/OCB](https://github.com/OCA/OCB) branch 16.0, addon `hw_posbox_homepage`. + +## License + +This package maintains the original LGPL-3 license from the upstream Odoo project. + +## Documentation + +- Overview: doc/OVERVIEW.md +- Architecture: doc/ARCHITECTURE.md +- Models: doc/MODELS.md +- Controllers: doc/CONTROLLERS.md +- Wizards: doc/WIZARDS.md +- Install: doc/INSTALL.md +- Usage: doc/USAGE.md +- Configuration: doc/CONFIGURATION.md +- Dependencies: doc/DEPENDENCIES.md +- Troubleshooting: doc/TROUBLESHOOTING.md +- FAQ: doc/FAQ.md diff --git a/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/ARCHITECTURE.md b/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/ARCHITECTURE.md new file mode 100644 index 0000000..63be931 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/ARCHITECTURE.md @@ -0,0 +1,32 @@ +# Architecture + +```mermaid +flowchart TD + U[Users] -->|HTTP| V[Views and QWeb Templates] + V --> C[Controllers] + V --> W[Wizards – Transient Models] + C --> M[Models and ORM] + W --> M + M --> R[Reports] + DX[Data XML] --> M + S[Security – ACLs and Groups] -. enforces .-> M + + subgraph Hw_posbox_homepage Module - hw_posbox_homepage + direction LR + M:::layer + W:::layer + C:::layer + V:::layer + R:::layer + S:::layer + DX:::layer + end + + classDef layer fill:#eef8ff,stroke:#6ea8fe,stroke-width:1px +``` + +Notes +- Views include tree/form/kanban templates and report templates. +- Controllers provide website/portal routes when present. +- Wizards are UI flows implemented with `models.TransientModel`. +- Data XML loads data/demo records; Security defines groups and access. diff --git a/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/CONFIGURATION.md b/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/CONFIGURATION.md new file mode 100644 index 0000000..38da08d --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/CONFIGURATION.md @@ -0,0 +1,3 @@ +# Configuration + +Refer to Odoo settings for hw_posbox_homepage. Configure related models, access rights, and options as needed. diff --git a/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/CONTROLLERS.md b/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/CONTROLLERS.md new file mode 100644 index 0000000..ff097c0 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/CONTROLLERS.md @@ -0,0 +1,17 @@ +# Controllers + +HTTP routes provided by this module. + +```mermaid +sequenceDiagram + participant U as User/Client + participant C as Module Controllers + participant O as ORM/Views + + U->>C: HTTP GET/POST (routes) + C->>O: ORM operations, render templates + O-->>U: HTML/JSON/PDF +``` + +Notes +- See files in controllers/ for route definitions. diff --git a/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/DEPENDENCIES.md b/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/DEPENDENCIES.md new file mode 100644 index 0000000..3b4b1b6 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/DEPENDENCIES.md @@ -0,0 +1,3 @@ +# Dependencies + +No explicit module dependencies declared. diff --git a/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/FAQ.md b/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/FAQ.md new file mode 100644 index 0000000..90a8185 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/FAQ.md @@ -0,0 +1,4 @@ +# FAQ + +- Q: Which Odoo version? A: 16.0 (OCA/OCB packaged). +- Q: How to enable? A: Start server with --addon hw_posbox_homepage or install in UI. diff --git a/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/INSTALL.md b/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/INSTALL.md new file mode 100644 index 0000000..2939859 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/INSTALL.md @@ -0,0 +1,7 @@ +# Install + +```bash +pip install odoo-bringout-oca-ocb-hw_posbox_homepage" +# or +uv pip install odoo-bringout-oca-ocb-hw_posbox_homepage" +``` diff --git a/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/MODELS.md b/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/MODELS.md new file mode 100644 index 0000000..eea61be --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/MODELS.md @@ -0,0 +1,11 @@ +# Models + +Detected core models and extensions in hw_posbox_homepage. + +```mermaid +classDiagram +``` + +Notes +- Classes show model technical names; fields omitted for brevity. +- Items listed under _inherit are extensions of existing models. diff --git a/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/OVERVIEW.md b/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/OVERVIEW.md new file mode 100644 index 0000000..4af0b38 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/OVERVIEW.md @@ -0,0 +1,6 @@ +# Overview + +Packaged Odoo addon: hw_posbox_homepage. Provides features documented in upstream Odoo 16 under this addon. + +- Source: OCA/OCB 16.0, addon hw_posbox_homepage +- License: LGPL-3 diff --git a/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/REPORTS.md b/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/REPORTS.md new file mode 100644 index 0000000..e0ea35f --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/REPORTS.md @@ -0,0 +1,3 @@ +# Reports + +This module does not define custom reports. diff --git a/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/SECURITY.md b/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/SECURITY.md new file mode 100644 index 0000000..e07da9d --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/SECURITY.md @@ -0,0 +1,8 @@ +# Security + +This module does not define custom security rules or access controls beyond Odoo defaults. + +Default Odoo security applies: +- Base user access through standard groups +- Model access inherited from dependencies +- No custom row-level security rules diff --git a/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/TROUBLESHOOTING.md b/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/TROUBLESHOOTING.md new file mode 100644 index 0000000..56853cb --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/TROUBLESHOOTING.md @@ -0,0 +1,5 @@ +# Troubleshooting + +- Ensure Python and Odoo environment matches repo guidance. +- Check database connectivity and logs if startup fails. +- Validate that dependent addons listed in DEPENDENCIES.md are installed. diff --git a/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/USAGE.md b/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/USAGE.md new file mode 100644 index 0000000..54981bf --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/USAGE.md @@ -0,0 +1,7 @@ +# Usage + +Start Odoo including this addon (from repo root): + +```bash +python3 scripts/nix_odoo_web_server.py --db-name mydb --addon hw_posbox_homepage +``` diff --git a/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/WIZARDS.md b/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/WIZARDS.md new file mode 100644 index 0000000..48e790d --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_posbox_homepage/doc/WIZARDS.md @@ -0,0 +1,3 @@ +# Wizards + +This module does not include UI wizards. diff --git a/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/__init__.py b/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/__init__.py new file mode 100644 index 0000000..dd15b5c --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import controllers diff --git a/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/__manifest__.py b/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/__manifest__.py new file mode 100644 index 0000000..fd4a03a --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/__manifest__.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'IoT Box Homepage', + 'category': 'Sales/Point of Sale', + 'sequence': 6, + 'website': 'https://www.odoo.com/app/point-of-sale-hardware', + 'summary': 'A homepage for the IoT Box', + 'description': """ +IoT Box Homepage +================ + +This module overrides Odoo web interface to display a simple +Homepage that explains what's the iotbox and shows the status, +and where to find documentation. + +If you activate this module, you won't be able to access the +regular Odoo interface anymore. + +""", + 'installable': False, + 'license': 'LGPL-3', +} diff --git a/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/controllers/__init__.py b/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/controllers/__init__.py new file mode 100644 index 0000000..5d4b25d --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/controllers/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import main diff --git a/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/controllers/main.py b/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/controllers/main.py new file mode 100644 index 0000000..e02cb6a --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/controllers/main.py @@ -0,0 +1,536 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import json +import jinja2 +import platform +import logging +import os +from pathlib import Path +import socket +import subprocess +import sys +import threading + +from odoo import http, service, tools +from odoo.http import Response, request +from odoo.modules.module import get_resource_path +from odoo.addons.hw_drivers.connection_manager import connection_manager +from odoo.addons.hw_drivers.main import iot_devices +from odoo.addons.hw_drivers.tools import helpers +from odoo.addons.hw_drivers.server_logger import check_and_update_odoo_config_log_to_server_option, close_server_log_sender_handler, get_odoo_config_log_to_server_option +from odoo.addons.web.controllers.home import Home + +_logger = logging.getLogger(__name__) + + +#---------------------------------------------------------- +# Controllers +#---------------------------------------------------------- + +if hasattr(sys, 'frozen'): + # When running on compiled windows binary, we don't have access to package loader. + path = os.path.realpath(os.path.join(os.path.dirname(__file__), '..', 'views')) + loader = jinja2.FileSystemLoader(path) +else: + loader = jinja2.PackageLoader('odoo.addons.hw_posbox_homepage', "views") + +jinja_env = jinja2.Environment(loader=loader, autoescape=True) +jinja_env.filters["json"] = json.dumps + +homepage_template = jinja_env.get_template('homepage.html') +server_config_template = jinja_env.get_template('server_config.html') +wifi_config_template = jinja_env.get_template('wifi_config.html') +handler_list_template = jinja_env.get_template('handler_list.html') +remote_connect_template = jinja_env.get_template('remote_connect.html') +configure_wizard_template = jinja_env.get_template('configure_wizard.html') +six_payment_terminal_template = jinja_env.get_template('six_payment_terminal.html') +list_credential_template = jinja_env.get_template('list_credential.html') +upgrade_page_template = jinja_env.get_template('upgrade_page.html') + +class IoTboxHomepage(Home): + def __init__(self): + super(IoTboxHomepage,self).__init__() + self.updating = threading.Lock() + + def clean_partition(self): + subprocess.check_call(['sudo', 'bash', '-c', '. /home/pi/odoo/addons/point_of_sale/tools/posbox/configuration/upgrade.sh; cleanup']) + + def get_six_terminal(self): + terminal_id = helpers.read_file_first_line('odoo-six-payment-terminal.conf') + return terminal_id or 'Not Configured' + + def get_homepage_data(self): + hostname = str(socket.gethostname()) + if platform.system() == 'Linux': + ssid = helpers.get_ssid() + wired = helpers.read_file_first_line('/sys/class/net/eth0/operstate') + else: + wired = 'up' + if wired == 'up': + network = 'Ethernet' + elif ssid: + if helpers.access_point(): + network = 'Wifi access point' + else: + network = 'Wifi : ' + ssid + else: + network = 'Not Connected' + + is_certificate_ok, certificate_details = helpers.get_certificate_status() + + iot_device = [] + for device in iot_devices: + iot_device.append({ + 'name': iot_devices[device].device_name + ' : ' + str(iot_devices[device].data['value']), + 'type': iot_devices[device].device_type.replace('_', ' '), + 'identifier': iot_devices[device].device_identifier, + }) + + return { + 'hostname': hostname, + 'ip': helpers.get_ip(), + 'mac': helpers.get_mac_address(), + 'iot_device_status': iot_device, + 'server_status': helpers.get_odoo_server_url() or 'Not Configured', + 'pairing_code': connection_manager.pairing_code, + 'six_terminal': self.get_six_terminal(), + 'network_status': network, + 'version': helpers.get_version(), + 'system': platform.system(), + 'is_certificate_ok': is_certificate_ok, + 'certificate_details': certificate_details, + } + + @http.route('/', type='http', auth='none') + def index(self): + wifi = Path.home() / 'wifi_network.txt' + remote_server = Path.home() / 'odoo-remote-server.conf' + if (wifi.exists() == False or remote_server.exists() == False) and helpers.access_point(): + return "" + else: + return homepage_template.render(self.get_homepage_data()) + + @http.route('/list_handlers', type='http', auth='none', website=True, csrf=False, save_session=False) + def list_handlers(self, **post): + AVAILABLE_LOG_LEVELS = ('debug', 'info', 'warning', 'error') + if request.httprequest.method == 'POST': + need_config_save = False # If the config file needed to be saved at the end + + # Check and update "send logs to server" + need_config_save |= check_and_update_odoo_config_log_to_server_option( + post.get('log-to-server') == 'on' # we use .get() as if the HTML checkbox is unchecked, no value is given in the POST request + ) + + # Check and update logging levels + IOT_LOGGING_PREFIX = 'iot-logging-' + INTERFACE_PREFIX = 'interface-' + DRIVER_PREFIX = 'driver-' + AVAILABLE_LOG_LEVELS_WITH_PARENT = AVAILABLE_LOG_LEVELS + ('parent',) + for post_request_key, log_level_or_parent in post.items(): + if not post_request_key.startswith(IOT_LOGGING_PREFIX): + # probably a new post request payload argument not related to logging + continue + post_request_key = post_request_key[len(IOT_LOGGING_PREFIX):] + + if post_request_key == 'root': + need_config_save |= self._update_logger_level('', log_level_or_parent, AVAILABLE_LOG_LEVELS) + elif post_request_key == 'odoo': + need_config_save |= self._update_logger_level('odoo', log_level_or_parent, AVAILABLE_LOG_LEVELS) + need_config_save |= self._update_logger_level('werkzeug', log_level_or_parent if log_level_or_parent != 'debug' else 'info', AVAILABLE_LOG_LEVELS) + elif post_request_key.startswith(INTERFACE_PREFIX): + logger_name = post_request_key[len(INTERFACE_PREFIX):] + need_config_save |= self._update_logger_level(logger_name, log_level_or_parent, AVAILABLE_LOG_LEVELS_WITH_PARENT, 'interfaces') + elif post_request_key.startswith(DRIVER_PREFIX): + logger_name = post_request_key[len(DRIVER_PREFIX):] + need_config_save |= self._update_logger_level(logger_name, log_level_or_parent, AVAILABLE_LOG_LEVELS_WITH_PARENT, 'drivers') + else: + _logger.warning('Unhandled iot logger: %s', post_request_key) + + # Update and save the config file (in case of IoT box reset) + if need_config_save: + with helpers.writable(): + tools.config.save() + drivers_list = helpers.list_file_by_os(get_resource_path('hw_drivers', 'iot_handlers', 'drivers')) + interfaces_list = helpers.list_file_by_os(get_resource_path('hw_drivers', 'iot_handlers', 'interfaces')) + return handler_list_template.render({ + 'title': "Odoo's IoT Box - Handlers list", + 'breadcrumb': 'Handlers list', + 'drivers_list': drivers_list, + 'interfaces_list': interfaces_list, + 'server': helpers.get_odoo_server_url(), + 'is_log_to_server_activated': get_odoo_config_log_to_server_option(), + 'root_logger_log_level': self._get_logger_effective_level_str(logging.getLogger()), + 'odoo_current_log_level': self._get_logger_effective_level_str(logging.getLogger('odoo')), + 'recommended_log_level': 'warning', + 'available_log_levels': AVAILABLE_LOG_LEVELS, + 'drivers_logger_info': self._get_iot_handlers_logger(drivers_list, 'drivers'), + 'interfaces_logger_info': self._get_iot_handlers_logger(interfaces_list, 'interfaces'), + }) + + @http.route('/load_iot_handlers', type='http', auth='none', website=True) + def load_iot_handlers(self): + helpers.download_iot_handlers(False) + helpers.odoo_restart(0) + return "" + + @http.route('/list_credential', type='http', auth='none', website=True) + def list_credential(self): + return list_credential_template.render({ + 'title': "Odoo's IoT Box - List credential", + 'breadcrumb': 'List credential', + 'db_uuid': helpers.read_file_first_line('odoo-db-uuid.conf'), + 'enterprise_code': helpers.read_file_first_line('odoo-enterprise-code.conf'), + }) + + @http.route('/save_credential', type='http', auth='none', cors='*', csrf=False) + def save_credential(self, db_uuid, enterprise_code): + helpers.write_file('odoo-db-uuid.conf', db_uuid) + helpers.write_file('odoo-enterprise-code.conf', enterprise_code) + helpers.odoo_restart(0) + return "" + + @http.route('/clear_credential', type='http', auth='none', cors='*', csrf=False) + def clear_credential(self): + helpers.unlink_file('odoo-db-uuid.conf') + helpers.unlink_file('odoo-enterprise-code.conf') + helpers.odoo_restart(0) + return "" + + @http.route('/wifi', type='http', auth='none', website=True) + def wifi(self): + return wifi_config_template.render({ + 'title': 'Wifi configuration', + 'breadcrumb': 'Configure Wifi', + 'loading_message': 'Connecting to Wifi', + 'ssid': helpers.get_wifi_essid(), + }) + + @http.route('/wifi_connect', type='http', auth='none', cors='*', csrf=False) + def connect_to_wifi(self, essid, password, persistent=False): + if persistent: + persistent = "1" + else: + persistent = "" + + subprocess.check_call([get_resource_path('point_of_sale', 'tools/posbox/configuration/connect_to_wifi.sh'), essid, password, persistent]) + server = helpers.get_odoo_server_url() + res_payload = { + 'message': 'Connecting to ' + essid, + } + if server: + res_payload['server'] = { + 'url': server, + 'message': 'Redirect to Odoo Server' + } + else: + res_payload['server'] = { + 'url': 'http://' + helpers.get_ip() + ':8069', + 'message': 'Redirect to IoT Box' + } + + return json.dumps(res_payload) + + @http.route('/wifi_clear', type='http', auth='none', cors='*', csrf=False) + def clear_wifi_configuration(self): + helpers.unlink_file('wifi_network.txt') + return "" + + @http.route('/server_clear', type='http', auth='none', cors='*', csrf=False) + def clear_server_configuration(self): + helpers.unlink_file('odoo-remote-server.conf') + close_server_log_sender_handler() + return "" + + @http.route('/handlers_clear', type='http', auth='none', cors='*', csrf=False) + def clear_handlers_list(self): + for directory in ['drivers', 'interfaces']: + for file in list(Path(get_resource_path('hw_drivers', 'iot_handlers', directory)).glob('*')): + if file.name != '__pycache__': + helpers.unlink_file(str(file.relative_to(*file.parts[:3]))) + return "" + + @http.route('/server_connect', type='http', auth='none', cors='*', csrf=False) + def connect_to_server(self, token, iotname): + if token: + credential = token.split('|') + url = credential[0] + token = credential[1] + db_uuid = credential[2] + enterprise_code = credential[3] + helpers.save_conf_server(url, token, db_uuid, enterprise_code) + else: + url = helpers.get_odoo_server_url() + token = helpers.get_token() + if iotname and platform.system() == 'Linux': + subprocess.check_call([get_resource_path('point_of_sale', 'tools/posbox/configuration/rename_iot.sh'), iotname]) + helpers.odoo_restart(5) + return 'http://' + helpers.get_ip() + ':8069' + + @http.route('/steps', type='http', auth='none', cors='*', csrf=False) + def step_by_step_configure_page(self): + return configure_wizard_template.render({ + 'title': 'Configure IoT Box', + 'breadcrumb': 'Configure IoT Box', + 'loading_message': 'Configuring your IoT Box', + 'ssid': helpers.get_wifi_essid(), + 'server': helpers.get_odoo_server_url() or '', + 'hostname': subprocess.check_output('hostname').decode('utf-8').strip('\n'), + }) + + @http.route('/step_configure', type='http', auth='none', cors='*', csrf=False) + def step_by_step_configure(self, token, iotname, essid, password, persistent=False): + if token: + url = token.split('|')[0] + token = token.split('|')[1] + else: + url = '' + subprocess.check_call([get_resource_path('point_of_sale', 'tools/posbox/configuration/connect_to_server_wifi.sh'), url, iotname, token, essid, password, persistent]) + return url + + # Set server address + @http.route('/server', type='http', auth='none', website=True) + def server(self): + return server_config_template.render({ + 'title': 'IoT -> Odoo server configuration', + 'breadcrumb': 'Configure Odoo Server', + 'hostname': subprocess.check_output('hostname').decode('utf-8').strip('\n'), + 'server_status': helpers.get_odoo_server_url() or 'Not configured yet', + 'loading_message': 'Configure Domain Server' + }) + + # Get password + @http.route('/hw_posbox_homepage/password', type='json', auth='none', methods=['POST']) + def view_password(self): + return helpers.generate_password() + + @http.route('/remote_connect', type='http', auth='none', cors='*') + def remote_connect(self): + """ + Establish a link with a customer box trough internet with a ssh tunnel + 1 - take a new auth_token on https://dashboard.ngrok.com/ + 2 - copy past this auth_token on the IoT Box : http://IoT_Box:8069/remote_connect + 3 - check on ngrok the port and url to get access to the box + 4 - you can connect to the box with this command : ssh -p port -v pi@url + """ + return remote_connect_template.render({ + 'title': 'Remote debugging', + 'breadcrumb': 'Remote Debugging', + }) + + @http.route('/enable_ngrok', type='http', auth='none', cors='*', csrf=False) + def enable_ngrok(self, auth_token): + if subprocess.call(['pgrep', 'ngrok']) == 1: + subprocess.Popen(['ngrok', 'tcp', '--authtoken', auth_token, '--log', '/tmp/ngrok.log', '22']) + return 'starting with ' + auth_token + else: + return 'already running' + + @http.route('/six_payment_terminal', type='http', auth='none', cors='*', csrf=False) + def six_payment_terminal(self): + return six_payment_terminal_template.render({ + 'title': 'Six Payment Terminal', + 'breadcrumb': 'Six Payment Terminal', + 'terminalId': self.get_six_terminal(), + }) + + @http.route('/six_payment_terminal_add', type='http', auth='none', cors='*', csrf=False) + def add_six_payment_terminal(self, terminal_id): + if terminal_id.isdigit(): + helpers.write_file('odoo-six-payment-terminal.conf', terminal_id) + service.server.restart() + else: + _logger.warning('Ignoring invalid Six TID: "%s". Only digits are allowed', terminal_id) + self.clear_six_payment_terminal() + return 'http://' + helpers.get_ip() + ':8069' + + @http.route('/six_payment_terminal_clear', type='http', auth='none', cors='*', csrf=False) + def clear_six_payment_terminal(self): + helpers.unlink_file('odoo-six-payment-terminal.conf') + service.server.restart() + return "" + + @http.route('/hw_proxy/upgrade', type='http', auth='none', ) + def upgrade(self): + commit = subprocess.check_output(["git", "--work-tree=/home/pi/odoo/", "--git-dir=/home/pi/odoo/.git", "log", "-1"]).decode('utf-8').replace("\n", "
") + flashToVersion = helpers.check_image() + actualVersion = helpers.get_version() + if flashToVersion: + flashToVersion = '%s.%s' % (flashToVersion.get('major', ''), flashToVersion.get('minor', '')) + return upgrade_page_template.render({ + 'title': "Odoo's IoTBox - Software Upgrade", + 'breadcrumb': 'IoT Box Software Upgrade', + 'loading_message': 'Updating IoT box', + 'commit': commit, + 'flashToVersion': flashToVersion, + 'actualVersion': actualVersion, + }) + + @http.route('/hw_proxy/perform_upgrade', type='http', auth='none') + def perform_upgrade(self): + self.updating.acquire() + os.system('/home/pi/odoo/addons/point_of_sale/tools/posbox/configuration/posbox_update.sh') + self.updating.release() + return 'SUCCESS' + + @http.route('/hw_proxy/get_version', type='http', auth='none') + def check_version(self): + return helpers.get_version() + + @http.route('/hw_proxy/perform_flashing_create_partition', type='http', auth='none') + def perform_flashing_create_partition(self): + try: + response = subprocess.check_output(['sudo', 'bash', '-c', '. /home/pi/odoo/addons/point_of_sale/tools/posbox/configuration/upgrade.sh; create_partition']).decode().split('\n')[-2] + if response in ['Error_Card_Size', 'Error_Upgrade_Already_Started']: + raise Exception(response) + return Response('success', status=200) + except subprocess.CalledProcessError as e: + raise Exception(e.output) + except Exception as e: + _logger.exception("Flashing create partition failed") + return Response(str(e), status=500) + + @http.route('/hw_proxy/perform_flashing_download_raspios', type='http', auth='none') + def perform_flashing_download_raspios(self): + try: + response = subprocess.check_output(['sudo', 'bash', '-c', '. /home/pi/odoo/addons/point_of_sale/tools/posbox/configuration/upgrade.sh; download_raspios']).decode().split('\n')[-2] + if response == 'Error_Raspios_Download': + raise Exception(response) + return Response('success', status=200) + except subprocess.CalledProcessError as e: + raise Exception(e.output) + except Exception as e: + self.clean_partition() + _logger.exception("Flashing download raspios failed") + return Response(str(e), status=500) + + @http.route('/hw_proxy/perform_flashing_copy_raspios', type='http', auth='none') + def perform_flashing_copy_raspios(self): + try: + response = subprocess.check_output(['sudo', 'bash', '-c', '. /home/pi/odoo/addons/point_of_sale/tools/posbox/configuration/upgrade.sh; copy_raspios']).decode().split('\n')[-2] + if response == 'Error_Iotbox_Download': + raise Exception(response) + return Response('success', status=200) + except subprocess.CalledProcessError as e: + raise Exception(e.output) + except Exception as e: + self.clean_partition() + _logger.exception("Flashing copy raspios failed") + return Response(str(e), status=500) + + @http.route('/iot_restart_odoo_or_reboot', type='json', auth='none', cors='*', csrf=False) + def iot_restart_odoo_or_reboot(self, action): + """ Reboots the IoT Box / restarts Odoo on it depending on chosen 'action' argument""" + try: + if action == 'restart_odoo': + helpers.odoo_restart(3) + else: + subprocess.call(['sudo', 'reboot']) + return 'success' + except Exception as e: + _logger.exception("Failed to restart/reboot the IoT") + return str(e) + + def _get_logger_effective_level_str(self, logger): + return logging.getLevelName(logger.getEffectiveLevel()).lower() + + def _get_iot_handler_logger(self, handler_name, handler_folder_name): + """ + Get Odoo Iot logger given an IoT handler name + :param handler_name: name of the IoT handler + :param handler_folder_name: IoT handler folder name (interfaces or drivers) + :return: logger if any, False otherwise + """ + odoo_addon_handler_path = helpers.compute_iot_handlers_addon_name(handler_folder_name, handler_name) + return odoo_addon_handler_path in logging.Logger.manager.loggerDict.keys() and \ + logging.getLogger(odoo_addon_handler_path) + + def _update_logger_level(self, logger_name, new_level, available_log_levels, handler_folder=False): + """ + Update (if necessary) Odoo's configuration and logger to the given logger_name to the given level. + The responsibility of saving the config file is not managed here. + :param logger_name: name of the logging logger to change level + :param new_level: new log level to set for this logger + :param available_log_levels: iterable of logs levels allowed (for initial check) + :param handler_folder: optional string of the IoT handler folder name ('interfaces' or 'drivers') + :return: wherever some changes were performed or not on the config + """ + if new_level not in available_log_levels: + _logger.warning('Unknown level to set on logger %s: %s', logger_name, new_level) + return False + + if handler_folder: + logger = self._get_iot_handler_logger(logger_name, handler_folder) + if not logger: + _logger.warning('Unable to change log level for logger %s as logger missing', logger_name) + return False + logger_name = logger.name + + ODOO_TOOL_CONFIG_HANDLER_NAME = 'log_handler' + LOG_HANDLERS = tools.config[ODOO_TOOL_CONFIG_HANDLER_NAME] + LOGGER_PREFIX = logger_name + ':' + IS_NEW_LEVEL_PARENT = new_level == 'parent' + + if not IS_NEW_LEVEL_PARENT: + intended_to_find = LOGGER_PREFIX + new_level.upper() + if intended_to_find in LOG_HANDLERS: + # There is nothing to do, the entry is already inside + return False + + # We remove every occurrence for the given logger + log_handlers_without_logger = [ + log_handler for log_handler in LOG_HANDLERS if not log_handler.startswith(LOGGER_PREFIX) + ] + + if IS_NEW_LEVEL_PARENT: + # We must check that there is no existing entries using this logger (whatever the level) + if len(log_handlers_without_logger) == len(LOG_HANDLERS): + return False + + # We add if necessary new logger entry + # If it is "parent" it means we want it to inherit from the parent logger. + # In order to do this we have to make sure that no entries for the logger exists in the + # `log_handler` (which is the case at this point as long as we don't re-add an entry) + tools.config[ODOO_TOOL_CONFIG_HANDLER_NAME] = log_handlers_without_logger + new_level_upper_case = new_level.upper() + if not IS_NEW_LEVEL_PARENT: + new_entry = [LOGGER_PREFIX + new_level_upper_case] + tools.config[ODOO_TOOL_CONFIG_HANDLER_NAME] += new_entry + _logger.debug('Adding to odoo config log_handler: %s', new_entry) + + # Update the logger dynamically + real_new_level = logging.NOTSET if IS_NEW_LEVEL_PARENT else new_level_upper_case + _logger.debug('Change logger %s level to %s', logger_name, real_new_level) + logging.getLogger(logger_name).setLevel(real_new_level) + return True + + def _get_iot_handlers_logger(self, handlers_name, iot_handler_folder_name): + """ + :param handlers_name: List of IoT handler string to search the loggers of + :param iot_handler_folder_name: name of the handler folder ('interfaces' or 'drivers') + :return: + { + : { + 'level': , + 'is_using_parent_level': , + 'parent_name': , + }, + ... + } + """ + handlers_loggers_level = dict() + for handler_name in handlers_name: + handler_logger = self._get_iot_handler_logger(handler_name, iot_handler_folder_name) + if not handler_logger: + # Might happen if the file didn't define a logger (or not init yet) + handlers_loggers_level[handler_name] = False + _logger.debug('Unable to find logger for handler %s', handler_name) + continue + logger_parent = handler_logger.parent + handlers_loggers_level[handler_name] = { + 'level': self._get_logger_effective_level_str(handler_logger), + 'is_using_parent_level': handler_logger.level == logging.NOTSET, + 'parent_name': logger_parent.name, + 'parent_level': self._get_logger_effective_level_str(logger_parent), + } + return handlers_loggers_level diff --git a/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/views/configure_wizard.html b/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/views/configure_wizard.html new file mode 100644 index 0000000..d492a50 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/views/configure_wizard.html @@ -0,0 +1,180 @@ +{% extends "layout.html" %} +{% from "loading.html" import loading_block_ui %} +{% block head %} + + +{% endblock %} +{% block content %} +

Configure IoT Box

+
    +
  • Connect to Odoo
  • +
  • Connect to Internet
  • +
  • Done
  • +
+
+
+
+ + + + + + + + + +
IoT Box Name
Server token
+
+ Server token is not mandatory for the community version. +
+ +
+
+ + + + + + + + + + +
Wifi Network + +
Password
+ +
+
+

✔ Nice! Your configuration is done.

+

+

+
+ {{ loading_block_ui(loading_message) }} +
+{% endblock %} diff --git a/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/views/handler_list.html b/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/views/handler_list.html new file mode 100644 index 0000000..c758df0 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/views/handler_list.html @@ -0,0 +1,128 @@ +{% extends "layout.html" %} +{% from "loading.html" import loading_block_ui %} +{% block head %} + +{% endblock %} +{% block content %} +

Logging

+
+ + +
+ + + + + +
+ +

Interfaces list

+ + + + + + {% for interface in interfaces_list -%} + + + + + {%- endfor %} +
NameLog Level
{{ interface }} + {% set interface_logger_info = interfaces_logger_info[interface] %} + {% if interface_logger_info != False %} + + {% else %} + Logger uninitialised + {% endif %} +
+ +

Drivers list

+ + + + + + {% for driver in drivers_list -%} + + + + + {%- endfor %} +
NameLog Level
{{ driver }} + {% set driver_logger_info = drivers_logger_info[driver] %} + {% if driver_logger_info != False %} + + {% else %} + Logger uninitialised + {% endif %} +
+ +
+ {% if server %} + Load handlers + {% endif %} + +
+
+ {% if server %} +
+ You can clear the handlers configuration +
+ +
+
+ {% endif %} + {{ loading_block_ui('Loading Handlers') }} +{% endblock %} diff --git a/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/views/homepage.html b/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/views/homepage.html new file mode 100644 index 0000000..3dc9f40 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/views/homepage.html @@ -0,0 +1,263 @@ +{% extends "layout.html" %} +{% from "loading.html" import loading_block_ui %} +{% block head %} + + +{% endblock %} +{% block content %} +
+
+
Restart
+
+
+ {% if system == "Linux" %} + + {% endif %} + +
+
+
+
+

Your IoT Box is up and running

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% if server_status != "Not Configured" %} + + + + + {% endif %} + {% if pairing_code %} + + + + + {% endif %} + + + + +
Name {{ hostname }} {% if system == "Linux" %}configure{% endif %}
Version {{ version }} {% if system == "Linux" %}update{% endif %}
IP Address{{ ip }}
Mac Address {{ mac }}
Network{{ network_status }} {% if system == "Linux" %}configure wifi{% endif %}
Server{{ server_status }}configure
HTTPS certificate + {% if is_certificate_ok %} +
+ OK + {{ certificate_details }} +
+ {% else %} + Error code: + {% set error_code = certificate_details.split(' ') | first | replace("_", "-") | lower %} + {% set doc_url = 'https://www.odoo.com/documentation/master/applications/productivity/iot/config/https_certificate_iot.html#' ~ error_code %} + help +
+ {{ certificate_details }} + {% endif %} +
Six payment terminal{{ six_terminal }} configure
Pairing code{{ pairing_code }}
IOT Device +
+ {% if iot_device_status|length == 0 %} + No Device Found + {% endif %} + {% for iot_devices in iot_device_status|groupby('type') %} +
+
{{ iot_devices.grouper|capitalize }}s
+
+ {% for device in iot_devices.list %} +
+ {{ device['name'] }} +
{{ device['identifier'] }}
+
+ {% endfor %} +
+
+ {% endfor %} +
+
handlers list
+
+
+ POS Display + {% if system == "Linux" %} + Remote Debug + Printers server + {% endif %} + {% if server_status != "Not Configured" %} + Credential + {% endif %} +
+ {{ loading_block_ui(loading_message) }} +{% endblock %} diff --git a/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/views/layout.html b/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/views/layout.html new file mode 100644 index 0000000..cde3ca1 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/views/layout.html @@ -0,0 +1,143 @@ + + + + + + {{ title or "Odoo's IoT Box" }} + + + {% block head %}{% endblock %} + + + {%if breadcrumb %} + + {% endif %} +
+ {% block content %}{% endblock %} +

+

+ + + diff --git a/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/views/list_credential.html b/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/views/list_credential.html new file mode 100644 index 0000000..87a8f09 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/views/list_credential.html @@ -0,0 +1,68 @@ +{% extends "layout.html" %} +{% from "loading.html" import loading_block_ui %} +{% block head %} + +{% endblock %} +{% block content %} +

List Credential

+

+ Set the DB UUID and your Contract Number you want to use. +

+
+ + + + + + + + + + + + +
DB uuid
Contract Number
+
+ {{ loading_block_ui(loading_message) }} +
+ {% if db_uuid or enterprise_code %} +

+ Current DB uuid: {{ db_uuid }} +

+

+ Current Contract Number: {{ enterprise_code }} +

+
+ You can clear the credential configuration +
+ +
+
+ {% endif %} +{% endblock %} diff --git a/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/views/loading.html b/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/views/loading.html new file mode 100644 index 0000000..7d65146 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/views/loading.html @@ -0,0 +1,14 @@ +{% macro loading_block_ui(message) %} +
+
+
+ Loading... +
+
+
+ Please wait..
+ {{ message }} +
+
+
+{% endmacro %} diff --git a/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/views/remote_connect.html b/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/views/remote_connect.html new file mode 100644 index 0000000..1967918 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/views/remote_connect.html @@ -0,0 +1,56 @@ +{% extends "layout.html" %} +{% block head %} + +{% endblock %} +{% block content %} +

Remote Debugging

+

+ This allows someone who give a ngrok authtoken to gain remote access to your IoT Box, and + thus your entire local network. Only enable this for someone + you trust. +

+ +{% endblock %} diff --git a/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/views/server_config.html b/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/views/server_config.html new file mode 100644 index 0000000..6824ee6 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/views/server_config.html @@ -0,0 +1,60 @@ +{% extends "layout.html" %} +{% from "loading.html" import loading_block_ui %} +{% block head %} + +{% endblock %} +{% block content %} +

Configure Odoo Server

+

+ Paste the token from the Connect wizard in your Odoo instance in the Server Token field. If you change the IoT Box Name, + your IoT Box will need a reboot. +

+
+ + + + + + + + + + + + +
IoT Box Name
Server Token
+
+

+ Your current server {{ server_status }} +

+ {{ loading_block_ui(loading_message) }} +
+
+ You can clear the server configuration +
+ +
+
+{% endblock %} diff --git a/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/views/six_payment_terminal.html b/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/views/six_payment_terminal.html new file mode 100644 index 0000000..89a2e69 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/views/six_payment_terminal.html @@ -0,0 +1,61 @@ +{% extends "layout.html" %} +{% from "loading.html" import loading_block_ui %} +{% block head %} + +{% endblock %} +{% block content %} +

Six Payment Terminal

+

+ Set the Terminal ID (TID) of the terminal you want to use. +

+
+ + + + + + + + +
Terminal ID (digits only)
+
+ {{ loading_block_ui(loading_message) }} +
+ {% if terminalId %} +

+ Current Terminal Id: {{ terminalId }} +

+
+ You can clear the terminal configuration +
+ +
+
+ {% endif %} +{% endblock %} diff --git a/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/views/upgrade_page.html b/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/views/upgrade_page.html new file mode 100644 index 0000000..3465968 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/views/upgrade_page.html @@ -0,0 +1,95 @@ +{% extends "layout.html" %} +{% from "loading.html" import loading_block_ui %} +{% block head %} + + + +{% endblock %} +{% block content %} +

IoT Box Software Upgrade

+

+ This tool will help you perform an upgrade of the IoTBox's software over the internet. + However the preferred method to upgrade the IoTBox is to flash the sd-card with + the latest image. The upgrade + procedure is explained into to the + IoTBox manual +

+

+ To upgrade the IoTBox, click on the upgrade button. The upgrade will take a few minutes. Do not reboot the IoTBox during the upgrade. +

+
+
+ Latest patch: +
+
{{ commit|safe }}
+
+
+ {% if flashToVersion %} + Upgrade to {{ flashToVersion }} + {% else %} + Upgrade + {% endif %} +
+ {{ loading_block_ui(loading_message) }} +{% endblock %} diff --git a/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/views/wifi_config.html b/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/views/wifi_config.html new file mode 100644 index 0000000..94e0579 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_posbox_homepage/hw_posbox_homepage/views/wifi_config.html @@ -0,0 +1,71 @@ +{% extends "layout.html" %} +{% from "loading.html" import loading_block_ui %} +{% block head %} + +{% endblock %} +{% block content %} +

Configure Wifi

+

+ Here you can configure how the iotbox should connect to wireless networks. + Currently only Open and WPA networks are supported. When enabling the persistent checkbox, + the chosen network will be saved and the iotbox will attempt to connect to it every time it boots. +

+
+ + + + + + + + + + + + + + + + +
ESSID + +
Password
Persistent
+
+
+
+ You can clear the persistent configuration +
+ +
+
+ {{ loading_block_ui(loading_message) }} +{% endblock %} diff --git a/odoo-bringout-oca-ocb-hw_posbox_homepage/pyproject.toml b/odoo-bringout-oca-ocb-hw_posbox_homepage/pyproject.toml new file mode 100644 index 0000000..75cc0c2 --- /dev/null +++ b/odoo-bringout-oca-ocb-hw_posbox_homepage/pyproject.toml @@ -0,0 +1,41 @@ +[project] +name = "odoo-bringout-oca-ocb-hw_posbox_homepage" +version = "16.0.0" +description = "IoT Box Homepage - A homepage for the IoT Box" +authors = [ + { name = "Ernad Husremovic", email = "hernad@bring.out.ba" } +] +dependencies = [ + "requests>=2.25.1" +] +readme = "README.md" +requires-python = ">= 3.11" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Office/Business", +] + +[project.urls] +homepage = "https://github.com/bringout/0" +repository = "https://github.com/bringout/0" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.build.targets.wheel] +packages = ["hw_posbox_homepage"] + +[tool.rye] +managed = true +dev-dependencies = [ + "pytest>=8.4.1", +]