diff --git a/.gitignore b/.gitignore index 4cb0a7a..16c5f59 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,6 @@ /astm_messages/ /test_messages/ /out/ +*.log +*.log.* .python-version diff --git a/docs/changelog.rst b/docs/changelog.rst index 35ebd70..353b517 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -2,9 +2,27 @@ Changelog ========= -1.0.0 (unreleased) ------------------- +2.0.0 +----- +- #38 Use microsecond precision in capture filenames +- #37 HL7 v2 parser, envelope mapping, LIMS push wiring (PR-7) +- #36 HL7-over-MLLP transport, passthrough (PR-6, HemoScreen) +- #35 Disk capture is a first-class pipeline handler (PR-H) +- #34 Server hardening: async main, sane log rotation, graceful shutdown (PR-G) +- #33 Split transport from protocol semantics (PR-F) +- #31 Migrate every instrument to the registry (PR-E2) +- #30 Introduce the instrument registry (PR-E1) +- #29 Make field descriptors quiet and tolerant +- #28 Define a typed Envelope schema for Wrapper.to_dict() +- #27 Lift LIMS push into core/ with typed errors and PushResult +- #26 Drop Python 2 compatibility shims + + +1.0.0 +----- + +- #25 Add test scaffold for the ASTM pipeline - #23 Add Cepheid GeneXpert import schema - #22 Add Horiba Pentra XLR import schema - #21 Add Biomérieux MINI VIDAS® import schema diff --git a/setup.py b/setup.py index 1424215..a01f81c 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ from setuptools import setup -version = "1.0.0" +version = "2.0.0" setup( name="senaite.astm", @@ -29,8 +29,9 @@ include_package_data=True, zip_safe=False, install_requires=[ + "hl7", + "pydantic>=2", "requests", - "zope.interface", ], test_suite='senaite.astm.tests', # List additional groups of dependencies here (e.g. development @@ -46,9 +47,11 @@ }, entry_points={ "console_scripts": [ - "senaite-astm-server=senaite.astm.server:main", + "senaite-astm-server=senaite.astm.cli.astm_server:main", "senaite-astm-send=senaite.astm.sender:main", "senaite-astm-simulator=senaite.astm.simulator:main", + "senaite-hl7-server=senaite.astm.cli.hl7_server:main", + "senaite-hl7-simulator=senaite.astm.cli.hl7_simulator:main", ] } ) diff --git a/src/senaite/astm/__init__.py b/src/senaite/astm/__init__.py index 6bdbba7..8139dbc 100644 --- a/src/senaite/astm/__init__.py +++ b/src/senaite/astm/__init__.py @@ -3,16 +3,8 @@ import logging import logging.handlers -from zope.interface.registry import Components - LOG_LEVEL = logging.INFO logger = logging.getLogger("senaite.astm") # Set the log level to LOG_LEVEL logger.setLevel(LOG_LEVEL) - -# global adapter registry -adapter_registry = Components() - -# Make sure adapters are initialized *after* the adapter registry is in place! -from senaite.astm import adapters # noqa: F401,E402 diff --git a/src/senaite/astm/adapters/__init__.py b/src/senaite/astm/adapters/__init__.py deleted file mode 100644 index cdca8ef..0000000 --- a/src/senaite/astm/adapters/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# -*- coding: utf-8 -*- - -from senaite.astm.adapters import biomerieux # noqa: F401 -from senaite.astm.adapters import spotchem # noqa: F401 diff --git a/src/senaite/astm/adapters/biomerieux/__init__.py b/src/senaite/astm/adapters/biomerieux/__init__.py deleted file mode 100644 index 3ad7c5c..0000000 --- a/src/senaite/astm/adapters/biomerieux/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# -*- coding: utf-8 -*- - -from . import mini_vidas # noqa: F401 diff --git a/src/senaite/astm/adapters/biomerieux/mini_vidas.py b/src/senaite/astm/adapters/biomerieux/mini_vidas.py deleted file mode 100644 index f2582b3..0000000 --- a/src/senaite/astm/adapters/biomerieux/mini_vidas.py +++ /dev/null @@ -1,123 +0,0 @@ -# -*- coding: utf-8 -*- - -import re -from datetime import datetime - -from senaite.astm import adapter_registry -from senaite.astm import utils -from senaite.astm.constants import ENQ -from senaite.astm.constants import EOT -from senaite.astm.constants import NAK -from senaite.astm.interfaces import IDataHandler -from senaite.astm.utils import f -from senaite.astm.utils import u -from zope.interface import implementer - -RX = ( - rb"\x02" # Start of the message - rb"(?:\x1emt(?P[^|]*))?" # Message Type (mt) - rb"(?:\|\x1epi(?P[^|]*))?" # Patient Identifier (pi) - rb"(?:\|\x1epn(?P[^|]*))?" # Patient Name (pn) - rb"(?:\|\x1epb(?P[^|]*))?" # Patient Birthdate (pb) - rb"(?:\|\x1eps(?P[^|]*))?" # Patient Sex (ps) - rb"(?:\|\x1eso(?P[^|]*))?" # Sample Origin (so) - rb"(?:\|\x1esi(?P[^|]*))?" # Specimen separator (si) - rb"(?:\|\x1eci(?P[^|]*))?" # Sample Identifier (ci) - rb"(?:\|\x1ert(?P[^|]*))?" # Short assay name (rt) - rb"(?:\|\x1ern(?P[^|]*))?" # Long assay name (rn) - rb"(?:\|\x1ett(?P[^|]*))?" # Test completion time (tt) - rb"(?:\|\x1etd(?P[^|]*))?" # Test completion date (td) - rb"(?:\|\x1eql(?P[^|]*))?" # Qualitative Result (ql) - rb"(?:\|\x1eqn(?P[^|]*))?" # Quantitative Result (qn) - rb"(?:\|\x1ey3(?P[^|]*))?" # Unit associated with qn (y3) - rb"(?:\|\x1eqd(?P[^|]*))?" # Dilution (qd) - rb"(?:\|\x1enc(?P[^|]*))?" # Vidas flags (nc) - rb"(?:\|\x1eid(?P[^|]*))?" # Instrument ID (id) - rb"(?:\|\x1esn(?P[^|]*))?" # Serial Number (sn) - rb"(?:\|\x1em4(?P[^|]*))?" # Technologist (m4) - rb"(?:\|\x1d(?P[a-fA-F0-9]{2}))$" # Checksum -) - - -@implementer(IDataHandler) -class DataHandler: - """Custom data handler for Biomérieux miniVidas - - We receive from this instrument a non valid ASTM message that need to be - handled differntly - """ - def __init__(self, protocol, data): - self.protocol = protocol - self.data = data - - def can_handle(self): - return re.match(RX, self.data) is not None - - def to_timestamp(self, date, time): - """Make a timestamp from the date and time - """ - dt = datetime.now() - if date: - dt = datetime.strptime(u(date), '%m/%d/%y') - if time: - t = datetime.strptime(u(time), '%H:%M').time() - dt = datetime.combine(dt, t) - return dt.strftime("%Y%m%d%H%M%S") - - def handle_data(self): - """Create a valid ASTM message of the received data - - 1. Create a static header record - 2. Create a results record with the given data - 3. Create a termination record - - """ - parts = re.match(RX, self.data) - if not parts: - return NAK - - # initialize the communication if we're not already in transfer state - # Note: This is mainly a test fixture for the simulator - if not self.protocol.in_transfer_state: - self.protocol.on_enq(ENQ) - - data = {} - for k, v in parts.groupdict().items(): - data[k] = u(v) if v else "" - - # convert date and time to a timestamp - date = data.get("td") - time = data.get("tt") - data["ts"] = self.to_timestamp(date, time) - - frames = [ - f("1H|\\^&|||miniVidas^biomerieux^1.0.0|||||||||{ts}{CR}{ETX}", - **data), - f("2P|1|||{pi}|{pn}||{pb}|{ps}||||||||||||||||||||||||||{CR}{ETX}", - **data), - f("3O|1|{ci}||{rn}||||||||||||||||||{ts}||||||||{CR}{ETX}", - **data), - f("4R|1|{rt}|{qn}|||{nc}||{ql}||{m4}||{ts}|{CR}{ETX}", - **data), - f("5L|1|N{CR}{ETX}"), - ] - messages = [] - for frame in frames: - cs = utils.make_checksum(frame) - messages.append( - f("{STX}{frame}{cs}{CRLF}", frame=u(frame), cs=u(cs))) - - # fill in the full message - self.protocol.messages = messages - - # end the communicaiton - self.protocol.on_eot(EOT) - - -# register the adapter -adapter_registry.registerAdapter( - DataHandler, - required=(object, object), - provided=IDataHandler, - name="mini_vidas", -) diff --git a/src/senaite/astm/adapters/spotchem/__init__.py b/src/senaite/astm/adapters/spotchem/__init__.py deleted file mode 100644 index a80ec88..0000000 --- a/src/senaite/astm/adapters/spotchem/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# -*- coding: utf-8 -*- - -from . import se1520 # noqa: F401 diff --git a/src/senaite/astm/adapters/spotchem/se1520.py b/src/senaite/astm/adapters/spotchem/se1520.py deleted file mode 100644 index e8ac834..0000000 --- a/src/senaite/astm/adapters/spotchem/se1520.py +++ /dev/null @@ -1,107 +0,0 @@ -# -*- coding: utf-8 -*- - -import re -from datetime import datetime - -from senaite.astm import adapter_registry -from senaite.astm import utils -from senaite.astm.constants import ENQ -from senaite.astm.constants import EOT -from senaite.astm.constants import NAK -from senaite.astm.interfaces import IDataHandler -from senaite.astm.utils import f -from senaite.astm.utils import u -from zope.interface import implementer - -RX = ( - rb"\x02" # Start of the message - rb"(\d{2}/\d{2}/\d{2})\s+" # Date in YY/MM/DD format - rb"(\d{2}:\d{2})\s+" # Time in HH:MM format - rb"ID#\s*([A-Z0-9\-_]+)\s+" # Sample ID prefixed by "ID#" - rb"\[(.*?)\]\s+" # Sample type in square brackets - rb"Na\s+([\d.]+)\s+(mmol/L)\s+" # Na result - rb"K\s+([\d.]+)\s+(mmol/L)\s+" # K result - rb"Cl\s+([\d.]+)\s+(mmol/L)" # Cl result - rb"\s*\x03" # End of the message -) - - -@implementer(IDataHandler) -class DataHandler: - """Custom data handler for Spotchem SE-1520 - - We receive from this instrument a non valid ASTM message that need to be - handled differntly - """ - def __init__(self, protocol, data): - self.protocol = protocol - self.data = data - - def can_handle(self): - return re.match(RX, self.data) is not None - - def handle_data(self): - """Create a valid ASTM message of the received data - - 1. Create a static header record - 2. Create a results record with the given data - 3. Create a termination record - - """ - parts = re.match(RX, self.data) - if not parts: - return NAK - - # initialize the communication if we're not already in transfer state - # Note: This is mainly a test fixture for the simulator - if not self.protocol.in_transfer_state: - self.protocol.on_enq(ENQ) - - date = parts.group(1).decode("utf-8") # Date - time = parts.group(2).decode("utf-8") # Time - sample_id = parts.group(3).decode("utf-8") # Sample ID - sample_type = parts.group(4).decode("utf-8") # Sample type - na_result = float(parts.group(5)) # Na result - na_unit = parts.group(6).decode("utf-8") # Na unit - k_result = float(parts.group(7)) # K result - k_unit = parts.group(8).decode("utf-8") # K unit - cl_result = float(parts.group(9)) # Cl result - cl_unit = parts.group(10).decode("utf-8") # Cl unit - - # convert date and time to a timestamp - dt = datetime.strptime(f"{date} {time}", "%y/%m/%d %H:%M") - timestamp = dt.strftime("%Y%m%d%H%M%S") - - frames = [ - f("1H|\\^&|||SE-1520^Spotchem^1.0.0|||||||||{ts}{CR}{ETX}", - ts=timestamp), - f("2O|1|{sid}||{stype}|||{ts}||||||||||||||||||{CR}{ETX}", - sid=sample_id, stype=sample_type, ts=timestamp), - f("3R|1|Na|{result}|{unit}||||||||{ts}|{CR}{ETX}", - result=na_result, unit=na_unit, ts=timestamp), - f("4R|2|K|{result}|{unit}||||||||{ts}|{CR}{ETX}", - result=k_result, unit=k_unit, ts=timestamp), - f("5R|3|Cl|{result}|{unit}||||||||{ts}|{CR}{ETX}", - result=cl_result, unit=cl_unit, ts=timestamp), - f("6L|1|N{CR}{ETX}"), - ] - messages = [] - for frame in frames: - cs = utils.make_checksum(frame) - messages.append( - f("{STX}{frame}{cs}{CRLF}", frame=u(frame), cs=u(cs))) - - # fill in the full message - self.protocol.messages = messages - - # end the communicaiton - self.protocol.on_eot(EOT) - - -# register the adapter -adapter_registry.registerAdapter( - DataHandler, - required=(object, object), - provided=IDataHandler, - name="spotchem_se1520", -) diff --git a/src/senaite/astm/cli/__init__.py b/src/senaite/astm/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/senaite/astm/cli/_runtime.py b/src/senaite/astm/cli/_runtime.py new file mode 100644 index 0000000..7e565b7 --- /dev/null +++ b/src/senaite/astm/cli/_runtime.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- +"""Shared CLI runtime helpers for the senaite.astm transport servers. + +The ASTM and HL7 CLI servers both: + +- attach a sane RotatingFileHandler to the package logger; +- validate that ``--output`` (when provided) points at an existing + directory; +- install loop-level SIGINT / SIGTERM handlers that flip a shared + ``asyncio.Event``; +- await in-flight pipeline tasks up to a configurable grace period + before forcefully cancelling them on shutdown. + +Keeping those bits here means each transport's ``cli/_server.py`` +focuses on its own wiring (protocol class, frame-callback, pipeline +shape) instead of re-implementing the lifecycle dance. +""" + +import asyncio +import logging +import logging.handlers +import os +import signal +import sys + +from senaite.astm import logger + +LOGFILE_MAX_BYTES = 10 * 1024 * 1024 +LOGFILE_BACKUP_COUNT = 5 +DEFAULT_SHUTDOWN_GRACE_SECONDS = 30 + + +def configure_logging(args): + """Attach the rotating logfile + stream handlers to the package + logger and set the verbosity from ``args.verbose``.""" + if getattr(args, "logfile", None): + handler = logging.handlers.RotatingFileHandler( + args.logfile, + maxBytes=LOGFILE_MAX_BYTES, + backupCount=LOGFILE_BACKUP_COUNT) + handler.setFormatter(logging.Formatter( + "%(asctime)s %(levelname)-8s %(message)s")) + logger.addHandler(handler) + + logger.setLevel( + logging.DEBUG if getattr(args, "verbose", False) else logging.INFO) + logger.addHandler(logging.StreamHandler()) + + +def validate_output(output): + """Exit with an error if ``output`` is set but not a directory.""" + if output and not os.path.isdir(output): + logger.error("Output path must be an existing directory") + sys.exit(-1) + + +def install_shutdown_handlers(loop, stop_event): + """Wire SIGINT / SIGTERM to ``stop_event``. + + Platforms where loop-level signal handlers are unsupported + (Windows, non-main-thread loops) silently fall back to the + default behaviour; callers that need programmatic shutdown + must drive ``stop_event`` directly. + """ + def request_shutdown(sig_name): + if stop_event.is_set(): + return + logger.info( + "Received %s, initiating graceful shutdown", sig_name) + stop_event.set() + + for sig in (signal.SIGINT, signal.SIGTERM): + try: + loop.add_signal_handler( + sig, request_shutdown, sig.name) + except (NotImplementedError, RuntimeError): + pass + + +async def drain_tasks(task_set, grace_seconds): + """Await every task in ``task_set`` up to ``grace_seconds``. + + Tasks still running after the grace period are cancelled. + """ + if not task_set: + return + logger.info( + "Waiting up to %ds for %d in-flight task(s) to finish...", + grace_seconds, len(task_set)) + pending = list(task_set) + done, still_pending = await asyncio.wait( + pending, timeout=grace_seconds) + if still_pending: + logger.warning( + "Cancelling %d task(s) that did not finish within " + "the %ds grace period", + len(still_pending), grace_seconds) + for task in still_pending: + task.cancel() + await asyncio.gather(*still_pending, return_exceptions=True) + + +def make_tracked_dispatcher(loop, pipeline, task_set): + """Build a frame-callback that schedules ``pipeline.run(payload)`` + as a tracked task on ``loop``. + + Transports invoke the returned callable synchronously from the + loop thread; the heavy work happens off the protocol's + ``data_received`` path so the next frame can be read immediately. + """ + def dispatch(client, payload): + task = loop.create_task(_run(payload, pipeline)) + task_set.add(task) + task.add_done_callback(task_set.discard) + return dispatch + + +async def _run(payload, pipeline): + try: + await pipeline.run(payload) + except Exception as exc: + logger.error("Pipeline run failed: %r", exc) diff --git a/src/senaite/astm/cli/astm_server.py b/src/senaite/astm/cli/astm_server.py new file mode 100644 index 0000000..b72ee4d --- /dev/null +++ b/src/senaite/astm/cli/astm_server.py @@ -0,0 +1,201 @@ +# -*- coding: utf-8 -*- +"""``senaite-astm-server`` CLI entry point. + +Wires the slim ASTM transport (``transports/astm/protocol.py``) to +the message :class:`Pipeline`. The transport emits complete frame +batches; this module wraps them into an :class:`Envelope`, dispatches +the pipeline as a tracked task, and waits for in-flight work on +shutdown. + +Lifecycle helpers (logging, signal handlers, task draining) live in +:mod:`senaite.astm.cli._runtime` and are shared with the HL7 CLI. +""" + +import argparse +import asyncio +import os + +from senaite.astm import logger +from senaite.astm.cli import _runtime +from senaite.astm.core import lims +from senaite.astm.core.lims import LimsPushHandler +from senaite.astm.core.output import DiskCaptureHandler +from senaite.astm.core.pipeline import Pipeline +from senaite.astm.transports.astm.protocol import ASTMProtocol +from senaite.astm.wrapper import Wrapper + +LOGFILE = "senaite-astm-server.log" + + +def build_arg_parser(): + parser = argparse.ArgumentParser( + formatter_class=argparse.ArgumentDefaultsHelpFormatter) + + astm_group = parser.add_argument_group("ASTM SERVER") + lims_group = parser.add_argument_group("SENAITE LIMS") + + astm_group.add_argument( + "-l", "--listen", type=str, default="0.0.0.0", + help="Listen IP address") + astm_group.add_argument( + "-p", "--port", type=str, default="4010", + help="Port to connect") + astm_group.add_argument( + "-o", "--output", type=str, + help="Output directory to write full messages") + astm_group.add_argument( + "--shutdown-grace-seconds", type=int, + default=_runtime.DEFAULT_SHUTDOWN_GRACE_SECONDS, + help="Seconds to wait for in-flight handler tasks to " + "finish before forcefully cancelling them on shutdown.") + + lims_group.add_argument( + "-u", "--url", type=str, + help="SENAITE URL address including username and password in " + "the format: http(s)://:@") + lims_group.add_argument( + "-c", "--consumer", type=str, + default="senaite.core.lis2a.import", + help="SENAITE push consumer interface") + lims_group.add_argument( + "-m", "--message-format", type=str, default="json", + help="Message format to send to SENAITE. " + "Allowed formats: 'astm', 'lis2a', 'json'.") + lims_group.add_argument( + "-r", "--retries", type=int, default=3, + help="Number of attempts of reconnection when SENAITE " + "instance is not reachable. Only has effect when " + "argument --url is set") + lims_group.add_argument( + "-d", "--delay", type=int, default=5, + help="Time delay in seconds between retries when SENAITE " + "instance is not reachable. Only has effect when " + "argument --url is set") + + parser.add_argument( + "-v", "--verbose", action="store_true", + help="Verbose logging") + parser.add_argument( + "--logfile", default=LOGFILE, + help="Path to store log files") + + return parser + + +def validate_lims(url): + if not url: + return None + session = lims.Session(url) + logger.info("Checking connection to SENAITE ...") + try: + session.auth() + except lims.SenaiteError as exc: + logger.error("Could not connect to SENAITE: {}".format(exc)) + raise SystemExit(-1) + return session + + +def build_pipeline(args, session): + handlers = [] + if args.output: + handlers.append(DiskCaptureHandler(os.path.abspath(args.output))) + if session is not None: + handlers.append(LimsPushHandler( + session, + retries=args.retries, + delay=args.delay, + consumer=args.consumer, + message_format=args.message_format, + )) + return Pipeline(handlers) + + +def make_frame_callback(loop, pipeline, task_set): + """ASTM-specific frame callback. + + The ASTM transport hands us a *list of frames* per session; we + wrap them into an :class:`Envelope` before running the pipeline. + """ + def frame_callback(client, frames): + task = loop.create_task( + _process_frames(client, frames, pipeline)) + task_set.add(task) + task.add_done_callback(task_set.discard) + return frame_callback + + +async def _process_frames(client, frames, pipeline): + try: + envelope = Wrapper(frames).to_envelope() + except Exception as exc: + logger.error( + "Failed to wrap %d frames from %s: %r", + len(frames), client, exc) + return + await pipeline.run(envelope) + + +async def amain(args, stop_event=None): + """Async entry point. + + Boots the listener, installs signal handlers, and blocks until a + shutdown signal arrives. + + :param stop_event: Optional pre-created :class:`asyncio.Event` + used to request shutdown. Tests can drive shutdown via this + event without going through OS signals. + """ + loop = asyncio.get_running_loop() + task_set = set() + pipeline = build_pipeline(args, args.session) + frame_callback = make_frame_callback(loop, pipeline, task_set) + + server = await loop.create_server( + lambda: ASTMProtocol(frame_callback=frame_callback), + host=args.listen, port=args.port) + + for socket in server.sockets: + ip, port = socket.getsockname() + logger.info("Starting server on {}:{}".format(ip, port)) + logger.info("ASTM server ready to handle connections ...") + + if stop_event is None: + stop_event = asyncio.Event() + _runtime.install_shutdown_handlers(loop, stop_event) + + try: + await stop_event.wait() + finally: + logger.info("Shutting down server...") + server.close() + await server.wait_closed() + await _runtime.drain_tasks(task_set, args.shutdown_grace_seconds) + logger.info("Server is now down...") + + +# Backwards compatibility for the lifecycle tests that import the +# pre-extraction helper names directly. +LOGFILE_MAX_BYTES = _runtime.LOGFILE_MAX_BYTES +LOGFILE_BACKUP_COUNT = _runtime.LOGFILE_BACKUP_COUNT +DEFAULT_SHUTDOWN_GRACE_SECONDS = _runtime.DEFAULT_SHUTDOWN_GRACE_SECONDS +configure_logging = _runtime.configure_logging +validate_output = _runtime.validate_output +_drain_tasks = _runtime.drain_tasks + + +def main(): + parser = build_arg_parser() + args = parser.parse_args() + + _runtime.configure_logging(args) + _runtime.validate_output(args.output) + args.session = validate_lims(args.url) + + try: + asyncio.run(amain(args)) + except KeyboardInterrupt: + logger.info("Interrupted; exiting.") + + +if __name__ == "__main__": + main() diff --git a/src/senaite/astm/cli/hl7_server.py b/src/senaite/astm/cli/hl7_server.py new file mode 100644 index 0000000..dfd9319 --- /dev/null +++ b/src/senaite/astm/cli/hl7_server.py @@ -0,0 +1,197 @@ +# -*- coding: utf-8 -*- +"""``senaite-hl7-server`` CLI entry point. + +Wires the HL7-over-MLLP transport +(:mod:`senaite.astm.transports.hl7.protocol`) to the message +pipeline. The transport hands us raw HL7 bytes per session; this +module parses them into an :class:`Envelope` (same shape the ASTM +transport produces) and runs the pipeline against it. + +LIMS push is enabled via ``--url`` the same way as +``senaite-astm-server``. Without ``--url`` the server stays +capture-only (default-off LIMS push per the HemoScreen plan PR-7). +""" + +import argparse +import asyncio +import os + +from senaite.astm import logger +from senaite.astm.cli import _runtime +from senaite.astm.core import lims +from senaite.astm.core.lims import LimsPushHandler +from senaite.astm.core.output import DiskCaptureHandler +from senaite.astm.core.pipeline import Pipeline +from senaite.astm.transports.hl7.parser import parse as parse_hl7 +from senaite.astm.transports.hl7.protocol import HL7Protocol + +LOGFILE = "senaite-hl7-server.log" +DEFAULT_PORT = "2575" + + +def _hl7_payload(envelope): + """DiskCaptureHandler extractor — write the HL7 raw text.""" + return envelope.metadata.hl7 or "" + + +def build_arg_parser(): + parser = argparse.ArgumentParser( + formatter_class=argparse.ArgumentDefaultsHelpFormatter) + + hl7_group = parser.add_argument_group("HL7 SERVER") + lims_group = parser.add_argument_group("SENAITE LIMS") + + hl7_group.add_argument( + "-l", "--listen", type=str, default="0.0.0.0", + help="Listen IP address") + hl7_group.add_argument( + "-p", "--port", type=str, default=DEFAULT_PORT, + help="Port to listen on (default: 2575, IANA-registered " + "for HL7)") + hl7_group.add_argument( + "-o", "--output", type=str, + help="Output directory to write captured HL7 messages") + hl7_group.add_argument( + "--shutdown-grace-seconds", type=int, + default=_runtime.DEFAULT_SHUTDOWN_GRACE_SECONDS, + help="Seconds to wait for in-flight handler tasks to " + "finish before forcefully cancelling them on shutdown.") + + lims_group.add_argument( + "-u", "--url", type=str, + help="SENAITE URL address including username and password in " + "the format: http(s)://:@. " + "Without --url the server runs in capture-only mode.") + lims_group.add_argument( + "-c", "--consumer", type=str, + default="senaite.core.hl7.import", + help="SENAITE push consumer interface") + lims_group.add_argument( + "-m", "--message-format", type=str, default="json", + help="Message format to send to SENAITE. " + "Allowed formats: 'json', 'hl7'.") + lims_group.add_argument( + "-r", "--retries", type=int, default=3, + help="Number of push attempts on transient failures. Only " + "applies when --url is set.") + lims_group.add_argument( + "-d", "--delay", type=int, default=5, + help="Seconds between push retries. Only applies when " + "--url is set.") + + parser.add_argument( + "-v", "--verbose", action="store_true", + help="Verbose logging") + parser.add_argument( + "--logfile", default=LOGFILE, + help="Path to store log files") + + return parser + + +def validate_lims(url): + if not url: + return None + session = lims.Session(url) + logger.info("Checking connection to SENAITE ...") + try: + session.auth() + except lims.SenaiteError as exc: + logger.error("Could not connect to SENAITE: {}".format(exc)) + raise SystemExit(-1) + return session + + +def build_pipeline(args, session): + handlers = [] + if args.output: + handlers.append(DiskCaptureHandler( + os.path.abspath(args.output), + payload=_hl7_payload, + ext=".hl7")) + if session is not None: + handlers.append(LimsPushHandler( + session, + retries=args.retries, + delay=args.delay, + consumer=args.consumer, + message_format=args.message_format, + )) + return Pipeline(handlers) + + +def make_frame_callback(loop, pipeline, task_set): + """Build the protocol callback that turns raw HL7 bytes into a + pipeline run. + + The transport hands us bytes already stripped of MLLP framing. + We parse them into an envelope and schedule a tracked task so + shutdown can wait for in-flight handlers. + """ + def frame_callback(client, hl7_bytes): + task = loop.create_task( + _process(client, hl7_bytes, pipeline)) + task_set.add(task) + task.add_done_callback(task_set.discard) + return frame_callback + + +async def _process(client, hl7_bytes, pipeline): + try: + envelope = parse_hl7(hl7_bytes) + except Exception as exc: + logger.error( + "Failed to parse HL7 message from %s: %r", client, exc) + return + await pipeline.run(envelope) + + +async def amain(args, stop_event=None): + loop = asyncio.get_running_loop() + task_set = set() + pipeline = build_pipeline(args, args.session) + frame_callback = make_frame_callback(loop, pipeline, task_set) + + server = await loop.create_server( + lambda: HL7Protocol(frame_callback=frame_callback), + host=args.listen, port=args.port) + + for socket in server.sockets: + ip, port = socket.getsockname() + logger.info("Starting HL7 server on {}:{}".format(ip, port)) + if args.session is None: + logger.info( + "HL7 server ready (capture-only; no --url configured)") + else: + logger.info("HL7 server ready to handle connections ...") + + if stop_event is None: + stop_event = asyncio.Event() + _runtime.install_shutdown_handlers(loop, stop_event) + + try: + await stop_event.wait() + finally: + logger.info("Shutting down HL7 server...") + server.close() + await server.wait_closed() + await _runtime.drain_tasks(task_set, args.shutdown_grace_seconds) + logger.info("HL7 server is now down...") + + +def main(): + parser = build_arg_parser() + args = parser.parse_args() + + _runtime.configure_logging(args) + _runtime.validate_output(args.output) + args.session = validate_lims(args.url) + + try: + asyncio.run(amain(args)) + except KeyboardInterrupt: + logger.info("Interrupted; exiting.") + + +if __name__ == "__main__": + main() diff --git a/src/senaite/astm/cli/hl7_simulator.py b/src/senaite/astm/cli/hl7_simulator.py new file mode 100644 index 0000000..f86ae67 --- /dev/null +++ b/src/senaite/astm/cli/hl7_simulator.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- +"""``senaite-hl7-simulator``: replay HL7 fixtures against a server. + +Reads one or more HL7 v2 fixtures from disk, wraps each in MLLP +framing, sends them sequentially over a single TCP connection to a +``senaite-hl7-server`` instance, and waits for the matching ACK +before sending the next. + +The fixture file may contain segments separated by ``\\n`` (typical +for human-edited captures) or ``\\r`` (HL7 on the wire); the +simulator normalises to ``\\r`` before sending. +""" + +import argparse +import asyncio +import logging +import os +import sys + +from senaite.astm import logger +from senaite.astm.transports.hl7.framing import MLLP_END +from senaite.astm.transports.hl7.framing import SB +from senaite.astm.transports.hl7.framing import extract_messages +from senaite.astm.transports.hl7.framing import wrap + + +def normalise(payload): + """Normalise newlines to HL7 segment terminators (``\\r``). + + Stripping any trailing terminator keeps the wrapped payload from + ending in a stray empty segment. + """ + payload = payload.replace(b"\r\n", b"\r").replace(b"\n", b"\r") + return payload.rstrip(b"\r") + + +async def send_fixture(host, port, path, delay): + with open(path, "rb") as fh: + payload = normalise(fh.read()) + + framed = wrap(payload) + logger.info("Sending %s (%d bytes payload)", path, len(payload)) + + reader, writer = await asyncio.open_connection(host, port) + try: + writer.write(framed) + await writer.drain() + + ack = await read_one_message(reader) + if ack is None: + logger.error("No ACK received for %s", path) + else: + logger.info("ACK received: %r", ack[:120]) + + if delay: + await asyncio.sleep(delay) + finally: + writer.close() + await writer.wait_closed() + + +async def read_one_message(reader, deadline=5.0): + """Read until exactly one MLLP-framed HL7 message has arrived.""" + buffer = b"" + while True: + try: + chunk = await asyncio.wait_for(reader.read(1024), + timeout=deadline) + except asyncio.TimeoutError: + return None + if not chunk: + return None + buffer += chunk + messages, buffer = extract_messages(buffer) + if messages: + return messages[0] + + +def build_arg_parser(): + parser = argparse.ArgumentParser( + formatter_class=argparse.ArgumentDefaultsHelpFormatter) + parser.add_argument( + "-a", "--address", type=str, default="127.0.0.1", + help="Server address") + parser.add_argument( + "-p", "--port", type=int, default=2575, + help="Server port") + parser.add_argument( + "-i", "--infile", type=str, nargs="+", required=True, + help="One or more HL7 fixture files to replay") + parser.add_argument( + "-d", "--delay", type=float, default=0.0, + help="Seconds to wait between fixtures") + parser.add_argument( + "-v", "--verbose", action="store_true", + help="Verbose logging") + return parser + + +async def amain(args): + for path in args.infile: + if not os.path.isfile(path): + logger.error("Fixture not found: %s", path) + return 1 + await send_fixture(args.address, args.port, path, args.delay) + return 0 + + +def main(): + args = build_arg_parser().parse_args() + logger.setLevel(logging.DEBUG if args.verbose else logging.INFO) + logger.addHandler(logging.StreamHandler()) + sys.exit(asyncio.run(amain(args)) or 0) + + +# Re-exports for tests that prefer to import the framing names from +# the simulator namespace. +__all__ = ["main", "send_fixture", "normalise", "SB", "MLLP_END"] + + +if __name__ == "__main__": + main() diff --git a/src/senaite/astm/codec.py b/src/senaite/astm/codec.py index 2ff1e5b..ebaf903 100644 --- a/src/senaite/astm/codec.py +++ b/src/senaite/astm/codec.py @@ -3,7 +3,8 @@ # Credits to Alexander Shorin: # https://github.com/kxepal/python-astm -from senaite.astm.compat import unicode +from collections.abc import Iterable + from senaite.astm.constants import COMPONENT_SEP from senaite.astm.constants import CR from senaite.astm.constants import CRLF @@ -18,16 +19,12 @@ from senaite.astm.utils import make_checksum from senaite.astm.utils import split -try: - from collections import Iterable -except ImportError: # Python 3 - from collections.abc import Iterable - # ############################################################################# # ASTM DECODE # ############################################################################# + def decode(data, encoding=ENCODING): """Common ASTM decoding function that tries to guess which kind of data it handles. @@ -245,14 +242,14 @@ def encode_record(record, encoding=ENCODING): for field in record: if isinstance(field, bytes): _append(field) - elif isinstance(field, unicode): + elif isinstance(field, str): _append(field.encode(encoding)) elif isinstance(field, Iterable): _append(encode_component(field, encoding)) elif field is None: _append(b"") else: - _append(unicode(field).encode(encoding)) + _append(str(field).encode(encoding)) return FIELD_SEP.join(fields) @@ -264,14 +261,14 @@ def encode_component(component, encoding=ENCODING): for item in component: if isinstance(item, bytes): _append(item) - elif isinstance(item, unicode): + elif isinstance(item, str): _append(item.encode(encoding)) elif isinstance(item, Iterable): return encode_repeated_component(component, encoding) elif item is None: _append(b"") else: - _append(unicode(item).encode(encoding)) + _append(str(item).encode(encoding)) return COMPONENT_SEP.join(items).rstrip(COMPONENT_SEP) diff --git a/src/senaite/astm/compat.py b/src/senaite/astm/compat.py deleted file mode 100644 index 594e3f6..0000000 --- a/src/senaite/astm/compat.py +++ /dev/null @@ -1,41 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Credits to Alexander Shorin: -# https://github.com/kxepal/python-astm - -import sys - -version = ".".join(map(str, sys.version_info[:2])) - -if version >= "3.0": - basestring = (str, bytes) - unicode = str - bytes = bytes - long = int - - def buffer(obj, start=None, stop=None): - memoryview(obj) - if start is None: - start = 0 - if stop is None: - stop = len(obj) - x = obj[start:stop] - return x -else: - basestring = basestring - unicode = unicode - b = bytes = str - long = long - buffer = buffer - -b = lambda s: isinstance(s, unicode) and s.encode("latin1") or s # noqa: E731 -u = lambda s: isinstance(s, bytes) and s.decode("utf-8") or s # noqa: E731 - - -def make_string(value): - if isinstance(value, unicode): - return value - elif isinstance(value, bytes): - return unicode(value, "utf-8") - else: - return unicode(value) diff --git a/src/senaite/astm/core/__init__.py b/src/senaite/astm/core/__init__.py new file mode 100644 index 0000000..40a96af --- /dev/null +++ b/src/senaite/astm/core/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/src/senaite/astm/core/envelope.py b/src/senaite/astm/core/envelope.py new file mode 100644 index 0000000..364b5da --- /dev/null +++ b/src/senaite/astm/core/envelope.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +"""Typed envelope schema produced by :class:`senaite.astm.wrapper.Wrapper`. + +The envelope is the JSON contract between `senaite.astm` and any +downstream LIMS consumer. Until the 2.x line, the shape was +implicit: a `defaultdict(list)` whose keys depended on which record +types happened to be present and whose per-record fields varied +silently between instruments. Consumers had to defensively probe +the structure (`obj.get("H", [{}])[0].get("sender", {})...`) and +every new instrument risked subtly changing the shape downstream +relied on. + +This module pins that contract: + +- :data:`ENVELOPE_VERSION` is bumped whenever the structure changes + in a way consumers must adapt to. +- :class:`Metadata` declares the required keys (``envelope_version``, + ``astm``, ``lis2a``) and accepts vendor extras (e.g. Roche c111's + parsed sender component) via ``extra="allow"``. +- :class:`Envelope` declares the per-record-type buckets (H, P, O, + R, C, M, L, Q) as lists of dicts. The per-record shape is left + loose on purpose — that lives in the per-instrument record + classes — but the *envelope* shape is now stable. +""" + +from typing import Any, Dict, List + +from pydantic import BaseModel, ConfigDict + +ENVELOPE_VERSION = "1.1" + + +class Metadata(BaseModel): + """Metadata block of the envelope. + + Required keys are declared explicitly. Per-instrument extras + (sender component, instrument type, etc.) are accepted via + ``extra="allow"`` and surface unchanged in :meth:`model_dump`. + + Transport-native raw representations live here so downstream + consumers can recover the exact bytes the instrument emitted. + Each transport populates the field that matches it and leaves + the others empty: + + - :attr:`astm` — ASTM frame batch, decoded. + - :attr:`lis2a` — LIS2-A flat string (ASTM payload without frame + headers and checksums). + - :attr:`hl7` — HL7 v2 message bytes, decoded. + + Soft defaults of ``""`` keep the schema backwards compatible: + consumers that read e.g. ``metadata["astm"]`` for an HL7 + envelope get an empty string rather than a missing key. + """ + + model_config = ConfigDict(extra="allow") + + envelope_version: str = ENVELOPE_VERSION + astm: str = "" + lis2a: str = "" + hl7: str = "" + + +class Envelope(BaseModel): + """Top-level envelope produced by ``Wrapper.to_dict()``. + + Per-record buckets default to empty lists so the shape is + stable regardless of which record types a given instrument + emits. + """ + + model_config = ConfigDict(extra="allow") + + metadata: Metadata + H: List[Dict[str, Any]] = [] + P: List[Dict[str, Any]] = [] + O: List[Dict[str, Any]] = [] # noqa: E741 + R: List[Dict[str, Any]] = [] + C: List[Dict[str, Any]] = [] + M: List[Dict[str, Any]] = [] + L: List[Dict[str, Any]] = [] + Q: List[Dict[str, Any]] = [] + + +def serialize_envelope(envelope, message_format="json"): + """Serialise an :class:`Envelope` according to ``message_format``. + + :param message_format: One of ``"json"`` (the typed envelope as + JSON), ``"astm"`` (the original framed bytes from + ``metadata.astm``) or ``"lis2a"`` (the LIS2-A flat string + from ``metadata.lis2a``). + + :returns: A string ready for downstream consumption. + :raises ValueError: when the format is unknown. + """ + if message_format == "json": + return envelope.model_dump_json() + if message_format == "astm": + return envelope.metadata.astm or "" + if message_format == "lis2a": + return envelope.metadata.lis2a or "" + if message_format == "hl7": + return envelope.metadata.hl7 or "" + raise ValueError("Unknown message_format: %r" % message_format) diff --git a/src/senaite/astm/core/instrument.py b/src/senaite/astm/core/instrument.py new file mode 100644 index 0000000..252d04d --- /dev/null +++ b/src/senaite/astm/core/instrument.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- +"""Instrument registry. + +A single mechanism for describing a supported analyzer: + +- a human-readable :attr:`name` (also used as the registry key), +- a :attr:`header_regex` matched against the first ASTM message to + identify the device, +- a :attr:`record_map` mapping record type characters (H, P, O, R, ...) + to the typed record class used to parse them, +- optional :meth:`preparse` and :meth:`get_metadata` hooks. + +Instruments register themselves at import time via the +:func:`register_instrument` decorator. :func:`find_instrument` +resolves an instrument from a raw header line; overlapping regexes +raise :class:`AmbiguousInstrumentError` rather than silently picking +one match. + +PR-E1 introduces the mechanism without migrating any instrument. +PR-E2 will move every instrument to this registry and remove the +legacy ``pkgutil``-based discovery in :mod:`senaite.astm.wrapper`. +""" + +import re + +from senaite.astm import logger + +_REGISTRY = {} + + +class AmbiguousInstrumentError(LookupError): + """Raised when more than one registered instrument matches the + same header. The caller must rename or tighten one of the + regexes; falling back to "first match wins" hides bugs. + """ + + +class Instrument(object): + """Base class for registered instruments. + + Subclasses set :attr:`name`, :attr:`header_regex`, and + :attr:`record_map`. Override :meth:`preparse` or + :meth:`get_metadata` when needed. + """ + + name = None + header_regex = None + record_map = None + #: Optional bytes regex used by :func:`find_raw_data_handler` + #: to dispatch non-ASTM transport packets to this instrument + #: before the standard ENQ/STX/EOT state machine sees them. + raw_data_regex = None + + def can_handle(self, raw_header): + """Return True if this instrument owns *raw_header*. + + Default implementation matches :attr:`header_regex` against + the bytes of the first ASTM frame. + """ + if self.header_regex is None: + return False + return re.match(self.header_regex, raw_header) is not None + + def can_handle_raw(self, data): + """Return True if this instrument owns the raw, non-ASTM + packet in *data*. Used by transports that wrap a custom + wire format (mini_vidas, spotchem se1520) and need a shot + at the bytes before the protocol's STX/ENQ dispatch. + """ + if self.raw_data_regex is None: + return False + return re.match(self.raw_data_regex, data) is not None + + def handle_raw_data(self, protocol, data): + """Hook for non-compliant transports. + + Default: ``None`` (no rewrite). Instruments whose wire + format is not valid ASTM override this to synthesise a + full ASTM session — typically by populating + ``protocol.messages`` and driving ``protocol.on_enq`` / + ``protocol.on_eot`` directly. + + :returns: bytes to write back to the device, or ``None`` + when the handler has already taken full responsibility. + """ + return None + + def get_metadata(self, wrapper): + """Optional per-instrument metadata merged into the envelope. + + Default returns an empty dict. + """ + return {} + + +def register_instrument(cls): + """Decorator: register an :class:`Instrument` subclass. + + Validates the class shape and instantiates it once. Re-registering + the same name replaces the previous entry (with a debug log) so + test fixtures can swap implementations. + """ + if not isinstance(cls, type) or not issubclass(cls, Instrument): + raise TypeError( + "register_instrument expects an Instrument subclass, " + "got %r" % (cls,)) + if not cls.name: + raise ValueError( + "Instrument %r must define a non-empty 'name'" % cls) + if cls.header_regex is None: + raise ValueError( + "Instrument %r must define 'header_regex'" % cls) + if not cls.record_map: + raise ValueError( + "Instrument %r must define a non-empty 'record_map'" % cls) + if cls.name in _REGISTRY: + logger.debug( + "Replacing already-registered instrument %r", cls.name) + _REGISTRY[cls.name] = cls() + return cls + + +def unregister_instrument(name): + """Remove *name* from the registry. No-op when absent. + + Intended for tests; production code should not call this. + """ + _REGISTRY.pop(name, None) + + +def registered_instruments(): + """Return a tuple of registered :class:`Instrument` instances. + + Order is the order of registration. + """ + return tuple(_REGISTRY.values()) + + +def find_raw_data_handler(data): + """Resolve a registered instrument that wants to handle the raw, + non-ASTM *data* packet directly. Mirrors :func:`find_instrument` + but consults :meth:`Instrument.can_handle_raw`. + + :raises AmbiguousInstrumentError: when more than one match. + """ + matches = [inst for inst in _REGISTRY.values() + if inst.can_handle_raw(data)] + if len(matches) > 1: + names = ", ".join(repr(m.name) for m in matches) + raise AmbiguousInstrumentError( + "Raw data matched multiple instruments: %s" % names) + if matches: + return matches[0] + return None + + +def find_instrument(raw_header): + """Resolve the instrument that owns *raw_header*. + + :param raw_header: bytes of the first ASTM message. + :returns: the matching :class:`Instrument` instance, or ``None`` + when no instrument claims the header. + :raises AmbiguousInstrumentError: when more than one registered + instrument matches. + """ + matches = [inst for inst in _REGISTRY.values() + if inst.can_handle(raw_header)] + if len(matches) > 1: + names = ", ".join(repr(m.name) for m in matches) + raise AmbiguousInstrumentError( + "Header matched multiple instruments: %s" % names) + if matches: + return matches[0] + return None + + +__all__ = ( + "AmbiguousInstrumentError", + "Instrument", + "find_instrument", + "find_raw_data_handler", + "register_instrument", + "registered_instruments", + "unregister_instrument", +) diff --git a/src/senaite/astm/core/lims.py b/src/senaite/astm/core/lims.py new file mode 100644 index 0000000..a2ac4e6 --- /dev/null +++ b/src/senaite/astm/core/lims.py @@ -0,0 +1,232 @@ +# -*- coding: utf-8 -*- + +import asyncio +from dataclasses import dataclass +from time import sleep +from typing import Optional + +import requests + +from senaite.astm import logger +from senaite.astm.core.envelope import serialize_envelope + +# SENAITE.JSONAPI route +API_BASE_URL = "@@API/senaite/v1" + +DEFAULT_RETRIES = 3 +DEFAULT_DELAY = 5 +DEFAULT_CONSUMER = "senaite.lis2a.import" + + +class SenaiteError(Exception): + """Base class for SENAITE LIMS push errors.""" + + +class SenaiteUnreachableError(SenaiteError): + """Raised when the SENAITE host cannot be reached at all.""" + + +class SenaiteHTTPError(SenaiteError): + """Raised when SENAITE responds with a non-200 status code.""" + + def __init__(self, message, status_code=None): + super().__init__(message) + self.status_code = status_code + + +class SenaiteAuthError(SenaiteError): + """Raised when authentication against SENAITE fails.""" + + +@dataclass +class PushResult: + """Outcome of a `post_to_senaite` invocation. + + :param success: True if a push attempt succeeded. + :param attempts: Number of push attempts that were made. + :param last_error: The exception from the final failed attempt, + or `None` on success. + """ + success: bool + attempts: int + last_error: Optional[Exception] = None + + +class Session(object): + """SENAITE Request Session. + + A single `requests.Session` is created in `__init__` and reused + for all calls, so the TLS handshake is amortised across the + connection rather than being repeated for every request. + """ + + def __init__(self, url, **kw): + auth = requests.utils.get_auth_from_url(url) + self.username = auth[0] + self.password = auth[1] + self.url = requests.utils.urldefragauth(url) + self._session = requests.Session() + self._session.auth = (self.username, self.password) + + @property + def session(self): + return self._session + + def auth(self): + """Authenticate against SENAITE. + + :returns: True on success. + :raises SenaiteAuthError: when the JSON API is missing or the + credentials are rejected. + :raises SenaiteUnreachableError: when the host cannot be + reached. + """ + logger.info("Starting session with SENAITE ...") + + version = self.get("version") + if not version or not version.get("version"): + raise SenaiteAuthError( + "senaite.jsonapi not found at {}".format(self.url)) + + user = self.get("users/current") + user = user.get("items", [{}])[0] + if not user or user.get("authenticated") is False: + raise SenaiteAuthError("Wrong username/password") + + logger.info("Session established ('{}') with '{}'" + .format(self.username, self.url)) + return True + + def post(self, endpoint, payload): + """Send a POST request to SENAITE. + + :returns: Parsed JSON response. + :raises SenaiteUnreachableError: on connection-level failures. + :raises SenaiteHTTPError: on non-200 responses. + """ + url = self.get_url(endpoint) + try: + response = self._session.post(url, data=payload) + except requests.RequestException as exc: + raise SenaiteUnreachableError( + "Could not POST to {}: {}".format(url, exc)) from exc + + if response.status_code != 200: + raise SenaiteHTTPError( + "POST {} returned {}".format(endpoint, response.status_code), + status_code=response.status_code) + + return response.json() + + def get(self, endpoint, timeout=60): + """Fetch the given endpoint and return parsed JSON. + + :raises SenaiteUnreachableError: on connection-level failures. + :raises SenaiteHTTPError: on non-200 responses. + """ + url = self.get_url(endpoint) + try: + response = self._session.get(url, timeout=timeout) + except requests.RequestException as exc: + raise SenaiteUnreachableError( + "Could not GET {}: {}".format(url, exc)) from exc + + if response.status_code != 200: + raise SenaiteHTTPError( + "GET {} returned {}".format(endpoint, response.status_code), + status_code=response.status_code) + + return response.json() + + def get_url(self, endpoint): + """Build an absolute API URL from an endpoint.""" + return "{}/{}/{}".format(self.url, API_BASE_URL, endpoint) + + +def post_to_senaite(messages, session, **kwargs): + """POST ASTM messages to SENAITE. + + Authenticates **once** per call. Retries on push failure only + re-call `session.post()`, not `session.auth()`. + + :returns: A :class:`PushResult` describing the outcome. + """ + retries = kwargs.get("retries", DEFAULT_RETRIES) + delay = kwargs.get("delay", DEFAULT_DELAY) + consumer = kwargs.get("consumer", DEFAULT_CONSUMER) + + try: + session.auth() + except SenaiteError as exc: + logger.error("Authentication failed: {}".format(exc)) + return PushResult(success=False, attempts=0, last_error=exc) + + payload = { + "consumer": consumer, + "messages": messages, + } + + last_error = None + for attempt in range(1, retries + 1): + try: + response = session.post("push", payload) + except SenaiteError as exc: + last_error = exc + logger.warning( + "Push attempt {}/{} failed: {}: {}".format( + attempt, retries, type(exc).__name__, exc)) + else: + if response.get("success"): + return PushResult( + success=True, attempts=attempt, last_error=None) + last_error = SenaiteHTTPError( + "SENAITE returned success=False: {!r}".format(response)) + logger.warning( + "Push attempt {}/{} rejected by SENAITE".format( + attempt, retries)) + + if attempt < retries: + sleep(delay) + + logger.error("Could not push the message after {} attempts".format( + retries)) + return PushResult( + success=False, attempts=retries, last_error=last_error) + + +class LimsPushHandler(object): + """Pipeline handler that pushes a serialised envelope to SENAITE. + + The handler is async-callable so it slots into + :class:`senaite.astm.core.pipeline.Pipeline` directly. The + blocking ``post_to_senaite`` runs via :func:`asyncio.to_thread` + so the event loop remains responsive while requests are in + flight. + """ + + name = "lims_push" + + def __init__(self, session, retries=DEFAULT_RETRIES, + delay=DEFAULT_DELAY, consumer=DEFAULT_CONSUMER, + message_format="json"): + self.session = session + self.retries = retries + self.delay = delay + self.consumer = consumer + self.message_format = message_format + + async def __call__(self, envelope): + payload = serialize_envelope(envelope, self.message_format) + result = await asyncio.to_thread( + post_to_senaite, + payload, + self.session, + retries=self.retries, + delay=self.delay, + consumer=self.consumer, + ) + if not result.success: + logger.error( + "LIMS push gave up after %d attempts: %r", + result.attempts, result.last_error) + return result diff --git a/src/senaite/astm/core/output.py b/src/senaite/astm/core/output.py new file mode 100644 index 0000000..16d901b --- /dev/null +++ b/src/senaite/astm/core/output.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +"""Disk-capture output handler for the message pipeline. + +Before PR-H, raw-message capture was an implicit side effect of +``protocol.log_message``: if a directory named ``astm_messages`` +existed in the server's current working directory, every message +landed there. The behaviour was undocumented, untestable in +isolation, and surprised every reader. + +PR-H promotes capture to a first-class :class:`Pipeline` handler. +The capture target is configured exclusively via ``--output``; no +filesystem is consulted to *discover* whether capture should be on. +The implicit ``$CWD/astm_messages/`` directory is gone. +""" + +import asyncio + +from senaite.astm.utils import write_message + + +def _default_payload(envelope): + """Default extractor: the raw ASTM payload from ``metadata.astm``.""" + return envelope.metadata.astm or "" + + +class DiskCaptureHandler(object): + """Persist a per-envelope payload to disk. + + :param path: Target directory. May not yet exist; it will be + created on first write. ``None`` or an empty string makes the + handler a no-op (used by the CLI when ``--output`` is not set). + :param payload: Callable that extracts the bytes / string to + write from an envelope. Defaults to + :attr:`Envelope.metadata.astm` so the ASTM CLI keeps its + historical behaviour; the HL7 CLI passes a lambda for + :attr:`Envelope.metadata.hl7`. + :param ext: File extension. Defaults to ``.txt`` to keep existing + ASTM captures intact; HL7 callers typically pass ``.hl7``. + + Each invocation writes one timestamped file containing the value + returned by ``payload(envelope)``. + """ + + name = "disk_capture" + + def __init__(self, path, payload=_default_payload, ext=".txt"): + self.path = path + self.payload = payload + self.ext = ext + + async def __call__(self, envelope): + if not self.path: + return + message = self.payload(envelope) or "" + await asyncio.to_thread(write_message, message, self.path, + ext=self.ext) diff --git a/src/senaite/astm/core/pipeline.py b/src/senaite/astm/core/pipeline.py new file mode 100644 index 0000000..19b97b2 --- /dev/null +++ b/src/senaite/astm/core/pipeline.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +"""Message pipeline. + +A :class:`Pipeline` runs a sequence of registered handlers against a +parsed :class:`~senaite.astm.core.envelope.Envelope`. Handlers are +async callables ``handler(envelope) -> awaitable``. They are invoked +in registration order. An exception raised by one handler is logged +and does not prevent later handlers from running. + +The transport layer (TCP listener, framing, wrapper) is intentionally +unaware of the pipeline. The CLI server wires the two together so +new outputs (S3 archive, message bus, metrics) become a new +``Handler`` rather than edits to ``server.main``. +""" + +import asyncio +import inspect + +from senaite.astm import logger + + +class Pipeline(object): + """Run handlers in order, isolating exceptions. + + :param handlers: Iterable of async or sync callables that accept + a single envelope argument. Sync callables are executed via + :func:`asyncio.to_thread`. + """ + + def __init__(self, handlers=None): + self.handlers = list(handlers or []) + + def add(self, handler): + """Append a handler to the pipeline.""" + self.handlers.append(handler) + + def __len__(self): + return len(self.handlers) + + async def run(self, envelope): + """Run every handler against ``envelope``. + + Returns a list of ``(handler_name, exception_or_None)`` in + the order the handlers were registered. + """ + results = [] + for handler in self.handlers: + name = self._handler_name(handler) + try: + await self._invoke(handler, envelope) + results.append((name, None)) + except Exception as exc: + logger.error( + "Pipeline handler %r failed: %r", name, exc) + results.append((name, exc)) + return results + + @staticmethod + async def _invoke(handler, envelope): + if inspect.iscoroutinefunction(handler): + return await handler(envelope) + result = handler(envelope) + if inspect.isawaitable(result): + return await result + return result + + @staticmethod + def _handler_name(handler): + return getattr(handler, "name", None) \ + or getattr(handler, "__name__", None) \ + or handler.__class__.__name__ + + +async def run_pipeline_in_thread(pipeline, envelope): + """Run a pipeline whose handlers are blocking (sync) in a thread. + + Convenience for callers that compose pipelines from sync handlers + only and want every handler off the event-loop thread. + """ + return await asyncio.to_thread(asyncio.run, pipeline.run(envelope)) diff --git a/src/senaite/astm/fields.py b/src/senaite/astm/fields.py index 8d7aa69..b4aab7d 100644 --- a/src/senaite/astm/fields.py +++ b/src/senaite/astm/fields.py @@ -7,14 +7,9 @@ import decimal import inspect import json -import time -import warnings from itertools import islice -from senaite.astm.compat import basestring -from senaite.astm.compat import long -from senaite.astm.compat import make_string -from senaite.astm.compat import unicode +from senaite.astm import logger class Field(object): @@ -48,7 +43,10 @@ def _get_value(self, value): return value def _set_value(self, value): - value = make_string(value) + if isinstance(value, bytes): + value = value.decode("utf-8") + else: + value = str(value) if self.length is not None and len(value) > self.length: raise ValueError("Field %r value is too long (max %d, got %d)" "" % (self.name, self.length, len(value))) @@ -56,10 +54,13 @@ def _set_value(self, value): class NotUsedField(Field): - """Mapping field for value that should be used. + """Mapping field for slots that the instrument may populate but + we deliberately don't model. - Acts as placeholder. On attempt to assign something to it raises - :exc:`UserWarning` and rejects assigned value. + Any assigned value is silently dropped. The field used to emit a + :exc:`UserWarning` per assignment, which drowned out real + warnings (the cobas_c311 fixture alone produced ~78 of them per + parse) without giving the operator anything actionable. """ def __init__(self, name=None): super(NotUsedField, self).__init__(name) @@ -68,8 +69,6 @@ def _get_value(self, value): return None def _set_value(self, value): - warnings.warn("Field %r is not used, any assignments are omitted" - "" % self.name, UserWarning) return None @@ -80,7 +79,7 @@ def _get_value(self, value): return int(value) def _set_value(self, value): - if not isinstance(value, (int, long)): + if not isinstance(value, int): try: value = self._get_value(value) except Exception: @@ -95,7 +94,7 @@ def _get_value(self, value): return decimal.Decimal(value) def _set_value(self, value): - if not isinstance(value, (int, long, float, decimal.Decimal)): + if not isinstance(value, (int, float, decimal.Decimal)): raise TypeError("Decimal value expected, got %r" % value) return super(DecimalField, self)._set_value(value) @@ -104,7 +103,7 @@ class TextField(Field): """Mapping field for string values. """ def _set_value(self, value): - if not isinstance(value, basestring): + if not isinstance(value, (str, bytes)): raise TypeError("String value expected, got %r" % value) return super(TextField, self)._set_value(value) @@ -127,16 +126,39 @@ def _get_value(self, value, default=None): return default +def _parse_with_formats(value, formats): + """Try each format in order, return the first match. + + :raises ValueError: when none of the formats match. + """ + for fmt in formats: + try: + return datetime.datetime.strptime(value, fmt) + except ValueError: + continue + raise ValueError("Value %r does not match any of: %s" + "" % (value, ", ".join(formats))) + + class DateField(Field): - """Mapping field for storing date/time values. + """Mapping field for storing date values. + + The canonical output format is :attr:`format`. Subclasses can + extend :attr:`parse_formats` to accept additional formats on + input; the canonical format is always tried first. """ format = "%Y%m%d" + parse_formats = () + + @property + def _accepted_formats(self): + return (self.format,) + tuple(self.parse_formats) def _get_value(self, value): - return datetime.datetime.strptime(value, self.format) + return _parse_with_formats(value, self._accepted_formats) def _set_value(self, value): - if isinstance(value, basestring): + if isinstance(value, (str, bytes)): value = self._get_value(value) if not isinstance(value, (datetime.datetime, datetime.date)): raise TypeError("Datetime value expected, got %r" % value) @@ -145,21 +167,27 @@ def _set_value(self, value): class TimeField(Field): """Mapping field for storing times. + + The canonical output format is :attr:`format`. Subclasses can + extend :attr:`parse_formats` to accept additional formats on + input; the canonical format is always tried first. """ format = "%H%M%S" + parse_formats = () + + @property + def _accepted_formats(self): + return (self.format,) + tuple(self.parse_formats) def _get_value(self, value): - if isinstance(value, basestring): - try: - value = value.split(".", 1)[0] # strip out microseconds - value = datetime.time(*time.strptime(value, self.format)[3:6]) - except ValueError: - raise ValueError("Value %r does not match format %s" - "" % (value, self.format)) + if isinstance(value, (str, bytes)): + value = value.split(".", 1)[0] # strip out microseconds + parsed = _parse_with_formats(value, self._accepted_formats) + return parsed.time() return value def _set_value(self, value): - if isinstance(value, basestring): + if isinstance(value, (str, bytes)): value = self._get_value(value) if not isinstance(value, (datetime.datetime, datetime.time)): raise TypeError("Datetime value expected, got %r" % value) @@ -170,14 +198,23 @@ def _set_value(self, value): class DateTimeField(Field): """Mapping field for storing date/time values. + + The canonical output format is :attr:`format`. Subclasses can + extend :attr:`parse_formats` to accept additional formats on + input; the canonical format is always tried first. """ format = "%Y%m%d%H%M%S" + parse_formats = () + + @property + def _accepted_formats(self): + return (self.format,) + tuple(self.parse_formats) def _get_value(self, value): - return datetime.datetime.strptime(value, self.format) + return _parse_with_formats(value, self._accepted_formats) def _set_value(self, value): - if isinstance(value, basestring): + if isinstance(value, (str, bytes)): value = self._get_value(value) if not isinstance(value, (datetime.datetime, datetime.date)): raise TypeError("Datetime value expected, got %r" % value) @@ -216,14 +253,23 @@ def _set_value(self, value): class SetField(Field): - """Mapping field for predefined set of values. + """Mapping field for a predefined set of values. + + By default, unknown values are accepted and a debug message is + logged. A device firmware update that introduces a new status + code should not crash parsing of every message that contains it. + + Pass ``strict=True`` to restore the legacy raise-on-unknown + behaviour (useful for tests or for fields whose vocabulary is + truly closed). """ def __init__(self, name=None, default=None, required=False, length=None, - values=None, field=Field()): + values=None, field=Field(), strict=False): super(SetField, self).__init__(name, default, required, length) self.field = field self.values = values and set(values) or set([]) + self.strict = strict def _get_value(self, value): return self.field._get_value(value) @@ -231,7 +277,12 @@ def _get_value(self, value): def _set_value(self, value): value = self.field._get_value(value) if value not in self.values: - raise ValueError("Unexpected value %r (%s)" % (value, self.name)) + if self.strict: + raise ValueError( + "Unexpected value %r (%s)" % (value, self.name)) + logger.debug( + "Field %r received unexpected value %r (allowed: %s)", + self.name, value, sorted(self.values)) return self.field._set_value(value) @@ -256,7 +307,7 @@ def _set_value(self, value): return self.mapping(**value) elif isinstance(value, self.mapping): return value - if isinstance(value, basestring): + if isinstance(value, (str, bytes)): value = [value] return self.mapping(*value) @@ -324,7 +375,7 @@ def __str__(self): return str(self.list) def __unicode__(self): - return unicode(self.list) + return str(self.list) def __delitem__(self, index): del self.list[index] diff --git a/src/senaite/astm/instruments/__init__.py b/src/senaite/astm/instruments/__init__.py index e69de29..2d5350b 100644 --- a/src/senaite/astm/instruments/__init__.py +++ b/src/senaite/astm/instruments/__init__.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +"""Import every instrument module so that the @register_instrument +decorators on each Instrument subclass run at package import time. +""" + +from senaite.astm.instruments import abbott_afinion2 # noqa: F401 +from senaite.astm.instruments import biomerieux_mini_vidas # noqa: F401 +from senaite.astm.instruments import dca_vantage # noqa: F401 +from senaite.astm.instruments import genexpert # noqa: F401 +from senaite.astm.instruments import horiba_pentra_xlr # noqa: F401 +from senaite.astm.instruments import horiba_yumizen_h5xx # noqa: F401 +from senaite.astm.instruments import roche_cobas_c111 # noqa: F401 +from senaite.astm.instruments import roche_cobas_c311 # noqa: F401 +from senaite.astm.instruments import spotchem_el # noqa: F401 +from senaite.astm.instruments import sysmex_xn # noqa: F401 +from senaite.astm.instruments import sysmex_xp # noqa: F401 diff --git a/src/senaite/astm/instruments/abbott_afinion2.py b/src/senaite/astm/instruments/abbott_afinion2.py index 4cdcda9..9034baf 100644 --- a/src/senaite/astm/instruments/abbott_afinion2.py +++ b/src/senaite/astm/instruments/abbott_afinion2.py @@ -1,6 +1,10 @@ # -*- coding: utf-8 -*- +import re + from senaite.astm import records +from senaite.astm.core.instrument import Instrument +from senaite.astm.core.instrument import register_instrument from senaite.astm.fields import ComponentField from senaite.astm.fields import ConstantField from senaite.astm.fields import DateTimeField @@ -11,7 +15,7 @@ from senaite.astm.mapping import Component VERSION = "1.0.0" -HEADER_RX = r".*Afinion 2 Analyzer\^" +HEADER_RX = re.compile(rb".*Afinion 2 Analyzer\^") PROCESSING_IDS = ( "P", # P: Patient measurement results @@ -35,32 +39,6 @@ ) -def get_metadata(wrapper): - """Additional metadata - - :param wrapper: The wrapper instance - :returns: dictionary of additional metadata - """ - return { - "version": VERSION, - "header_rx": HEADER_RX, - } - - -def get_mapping(): - """Returns the wrappers for this instrument - """ - return { - "H": HeaderRecord, - "P": PatientRecord, - "O": OrderRecord, - "R": ResultRecord, - "Q": RequestInformationRecord, - "M": ManufacturerInfoRecord, - "L": TerminatorRecord, - } - - class HeaderRecord(records.HeaderRecord): """Message Header Record (H) @@ -197,3 +175,24 @@ class ManufacturerInfoRecord(records.ManufacturerInfoRecord): class TerminatorRecord(records.TerminatorRecord): """Message Termination Record (L) """ + + +@register_instrument +class AbbottAfinion2(Instrument): + name = "abbott_afinion2" + header_regex = HEADER_RX + record_map = { + "H": HeaderRecord, + "P": PatientRecord, + "O": OrderRecord, + "R": ResultRecord, + "Q": RequestInformationRecord, + "M": ManufacturerInfoRecord, + "L": TerminatorRecord, + } + + def get_metadata(self, wrapper): + return {"version": VERSION, "header_rx": HEADER_RX.pattern.decode()} + + +INSTRUMENT = AbbottAfinion2() diff --git a/src/senaite/astm/instruments/biomerieux_mini_vidas.py b/src/senaite/astm/instruments/biomerieux_mini_vidas.py index b97a469..e71d14d 100644 --- a/src/senaite/astm/instruments/biomerieux_mini_vidas.py +++ b/src/senaite/astm/instruments/biomerieux_mini_vidas.py @@ -1,38 +1,51 @@ # -*- coding: utf-8 -*- +import re +from datetime import datetime + from senaite.astm import records +from senaite.astm import utils +from senaite.astm.constants import ENQ +from senaite.astm.constants import EOT +from senaite.astm.constants import NAK +from senaite.astm.core.instrument import Instrument +from senaite.astm.core.instrument import register_instrument from senaite.astm.fields import ComponentField, DateField from senaite.astm.fields import DateTimeField from senaite.astm.fields import TextField from senaite.astm.mapping import Component +from senaite.astm.utils import f as fmt +from senaite.astm.utils import u VERSION = "1.0.0" # Supports Biomérieux miniVidas -HEADER_RX = r".*miniVidas\^" - - -def get_metadata(wrapper): - """Additional metadata - - :param wrapper: The wrapper instance - :returns: dictionary of additional metadata - """ - return { - "version": VERSION, - "header_rx": HEADER_RX, - } - - -def get_mapping(): - """Returns the wrappers for this instrument - """ - return { - "H": HeaderRecord, - "P": PatientRecord, - "O": OrderRecord, - "R": ResultRecord, - "L": TerminatorRecord, - } +HEADER_RX = re.compile(rb".*miniVidas\^") + +# Raw, non-ASTM wire format emitted by the miniVidas. +RAW_DATA_RX = re.compile( + rb"\x02" # Start of the message + rb"(?:\x1emt(?P[^|]*))?" # Message Type + rb"(?:\|\x1epi(?P[^|]*))?" # Patient Identifier + rb"(?:\|\x1epn(?P[^|]*))?" # Patient Name + rb"(?:\|\x1epb(?P[^|]*))?" # Patient Birthdate + rb"(?:\|\x1eps(?P[^|]*))?" # Patient Sex + rb"(?:\|\x1eso(?P[^|]*))?" # Sample Origin + rb"(?:\|\x1esi(?P[^|]*))?" # Specimen separator + rb"(?:\|\x1eci(?P[^|]*))?" # Sample Identifier + rb"(?:\|\x1ert(?P[^|]*))?" # Short assay name + rb"(?:\|\x1ern(?P[^|]*))?" # Long assay name + rb"(?:\|\x1ett(?P[^|]*))?" # Test completion time + rb"(?:\|\x1etd(?P[^|]*))?" # Test completion date + rb"(?:\|\x1eql(?P[^|]*))?" # Qualitative Result + rb"(?:\|\x1eqn(?P[^|]*))?" # Quantitative Result + rb"(?:\|\x1ey3(?P[^|]*))?" # Unit associated with qn + rb"(?:\|\x1eqd(?P[^|]*))?" # Dilution + rb"(?:\|\x1enc(?P[^|]*))?" # Vidas flags + rb"(?:\|\x1eid(?P[^|]*))?" # Instrument ID + rb"(?:\|\x1esn(?P[^|]*))?" # Serial Number + rb"(?:\|\x1em4(?P[^|]*))?" # Technologist + rb"(?:\|\x1d(?P[a-fA-F0-9]{2}))$" # Checksum +) class HeaderRecord(records.HeaderRecord): @@ -77,3 +90,73 @@ class ResultRecord(records.ResultRecord): class TerminatorRecord(records.TerminatorRecord): """Message Termination Record (L) """ + + +@register_instrument +class BiomerieuxMiniVidas(Instrument): + name = "biomerieux_mini_vidas" + header_regex = HEADER_RX + raw_data_regex = RAW_DATA_RX + record_map = { + "H": HeaderRecord, + "P": PatientRecord, + "O": OrderRecord, + "R": ResultRecord, + "L": TerminatorRecord, + } + + def get_metadata(self, wrapper): + return {"version": VERSION, + "header_rx": HEADER_RX.pattern.decode()} + + def _to_timestamp(self, date, time): + dt = datetime.now() + if date: + dt = datetime.strptime(u(date), "%m/%d/%y") + if time: + t = datetime.strptime(u(time), "%H:%M").time() + dt = datetime.combine(dt, t) + return dt.strftime("%Y%m%d%H%M%S") + + def handle_raw_data(self, protocol, data): + """Synthesise a complete ASTM session from a single non-ASTM + packet emitted by the miniVidas. Drives ``protocol`` directly. + """ + parts = re.match(RAW_DATA_RX, data) + if not parts: + return NAK + if not protocol.in_transfer_state: + protocol.on_enq(ENQ) + + values = {k: (u(v) if v else "") + for k, v in parts.groupdict().items()} + values["ts"] = self._to_timestamp( + values.get("td"), values.get("tt")) + + frames = [ + fmt("1H|\\^&|||miniVidas^biomerieux^1.0.0|||||||||{ts}{CR}{ETX}", + **values), + fmt( + "2P|1|||{pi}|{pn}||{pb}|{ps}||||||||||||||||||||||||||" + "{CR}{ETX}", + **values), + fmt( + "3O|1|{ci}||{rn}||||||||||||||||||{ts}||||||||{CR}{ETX}", + **values), + fmt( + "4R|1|{rt}|{qn}|||{nc}||{ql}||{m4}||{ts}|{CR}{ETX}", + **values), + fmt("5L|1|N{CR}{ETX}"), + ] + messages = [] + for frame in frames: + cs = utils.make_checksum(frame) + messages.append( + fmt("{STX}{frame}{cs}{CRLF}", frame=u(frame), cs=u(cs))) + + protocol.messages = messages + protocol.on_eot(EOT) + return None + + +INSTRUMENT = BiomerieuxMiniVidas() diff --git a/src/senaite/astm/instruments/dca_vantage.py b/src/senaite/astm/instruments/dca_vantage.py index 065576c..cbe4090 100644 --- a/src/senaite/astm/instruments/dca_vantage.py +++ b/src/senaite/astm/instruments/dca_vantage.py @@ -1,6 +1,10 @@ # -*- coding: utf-8 -*- +import re + from senaite.astm import records +from senaite.astm.core.instrument import Instrument +from senaite.astm.core.instrument import register_instrument from senaite.astm.fields import ComponentField from senaite.astm.fields import ConstantField from senaite.astm.fields import DateTimeField @@ -13,7 +17,7 @@ # Siemens Healthcare Diagnostics, DCA Vantage® Analyzer, Host Computer # Communications Link, 17306 Rev. E 2012-06, 2012 -HEADER_RX = r".*(DCA VANTAGE|DCA Vantage)\^" +HEADER_RX = re.compile(rb".*(DCA VANTAGE|DCA Vantage)\^") PROCESSING_IDS = ( "P", # P: Production (the message contains clinical results) @@ -42,32 +46,6 @@ ) -def get_metadata(wrapper): - """Additional metadata - :param wrapper: The wrapper instance - :returns: dictionary of additional metadata - """ - return { - "version": VERSION, - "header_rx": HEADER_RX, - } - - -def get_mapping(): - """Returns the wrappers for this instrument - """ - return { - "H": HeaderRecord, - "P": PatientRecord, - "O": OrderRecord, - "R": ResultRecord, - "C": CommentRecord, - "Q": RequestInformationRecord, - "M": ManufacturerInfoRecord, - "L": TerminatorRecord, - } - - class HeaderRecord(records.HeaderRecord): """Message Header Record (H) """ @@ -178,3 +156,25 @@ class ManufacturerInfoRecord(records.ManufacturerInfoRecord): class TerminatorRecord(records.TerminatorRecord): """Message Termination Record (L) """ + + +@register_instrument +class DCAVantage(Instrument): + name = "dca_vantage" + header_regex = HEADER_RX + record_map = { + "H": HeaderRecord, + "P": PatientRecord, + "O": OrderRecord, + "R": ResultRecord, + "C": CommentRecord, + "Q": RequestInformationRecord, + "M": ManufacturerInfoRecord, + "L": TerminatorRecord, + } + + def get_metadata(self, wrapper): + return {"version": VERSION, "header_rx": HEADER_RX.pattern.decode()} + + +INSTRUMENT = DCAVantage() diff --git a/src/senaite/astm/instruments/genexpert.py b/src/senaite/astm/instruments/genexpert.py index 4157432..d7882f1 100644 --- a/src/senaite/astm/instruments/genexpert.py +++ b/src/senaite/astm/instruments/genexpert.py @@ -1,8 +1,12 @@ # -*- coding: utf-8 -*- +import re + from datetime import datetime from senaite.astm import records +from senaite.astm.core.instrument import Instrument +from senaite.astm.core.instrument import register_instrument from senaite.astm.fields import ComponentField from senaite.astm.fields import DateTimeField from senaite.astm.fields import RepeatedComponentField @@ -19,7 +23,7 @@ # - GeneXpert Xpress 5.1 and above # - Infinity Xpertise v6.4b and above # - Cepheid OS 1.0 (GeneXpert® System with Touchscreen) and above -HEADER_RX = r".*(GeneXpert)\^" +HEADER_RX = re.compile(rb".*(GeneXpert)\^") PRIORITIES = ( "S", # S: Stat @@ -79,32 +83,6 @@ ) -def get_metadata(wrapper): - """Additional metadata - :param wrapper: The wrapper instance - :returns: dictionary of additional metadata - """ - return { - "version": VERSION, - "header_rx": HEADER_RX, - } - - -def get_mapping(): - """Returns the wrappers for this instrument - """ - return { - "H": HeaderRecord, - "P": PatientRecord, - "O": OrderRecord, - "R": ResultRecord, - "C": CommentRecord, - "Q": RequestInformationRecord, - "M": ManufacturerInfoRecord, - "L": TerminatorRecord, - } - - class HeaderRecord(records.HeaderRecord): """Message Header Record (H) """ @@ -301,3 +279,26 @@ class ManufacturerInfoRecord(records.ManufacturerInfoRecord): class TerminatorRecord(records.TerminatorRecord): """Message Termination Record (L) """ + + +@register_instrument +class GeneXpert(Instrument): + name = "genexpert" + header_regex = HEADER_RX + record_map = { + "H": HeaderRecord, + "P": PatientRecord, + "O": OrderRecord, + "R": ResultRecord, + "C": CommentRecord, + "Q": RequestInformationRecord, + "M": ManufacturerInfoRecord, + "L": TerminatorRecord, + } + + def get_metadata(self, wrapper): + return {"version": VERSION, + "header_rx": HEADER_RX.pattern.decode()} + + +INSTRUMENT = GeneXpert() diff --git a/src/senaite/astm/instruments/horiba_pentra_xlr.py b/src/senaite/astm/instruments/horiba_pentra_xlr.py index 64b9412..836b599 100644 --- a/src/senaite/astm/instruments/horiba_pentra_xlr.py +++ b/src/senaite/astm/instruments/horiba_pentra_xlr.py @@ -1,6 +1,10 @@ # -*- coding: utf-8 -*- +import re + from senaite.astm import records +from senaite.astm.core.instrument import Instrument +from senaite.astm.core.instrument import register_instrument from senaite.astm.fields import ComponentField from senaite.astm.fields import DateField from senaite.astm.fields import DateTimeField @@ -11,34 +15,7 @@ from senaite.astm.mapping import Component VERSION = "1.0.0" -HEADER_RX = r".*(?<=\|)(LIS|ABX)(?=\|).*" - - -def get_metadata(wrapper): - """Additional metadata - - :param wrapper: The wrapper instance - :returns: dictionary of additional metadata - """ - return { - "version": VERSION, - "header_rx": HEADER_RX, - } - - -def get_mapping(): - """Returns the wrappers for this instrument - """ - return { - "H": HeaderRecord, - "P": PatientRecord, - "O": OrderRecord, - "R": ResultRecord, - "C": CommentRecord, - "Q": RequestInformationRecord, - "M": ManufacturerInfoRecord, - "L": TerminatorRecord, - } +HEADER_RX = re.compile(rb".*(?<=\|)(LIS|ABX)(?=\|).*") class HeaderRecord(records.HeaderRecord): @@ -215,3 +192,26 @@ class ManufacturerInfoRecord(records.ManufacturerInfoRecord): class TerminatorRecord(records.TerminatorRecord): """Message Termination Record (L) """ + + +@register_instrument +class HoribaPentraXLR(Instrument): + name = "horiba_pentra_xlr" + header_regex = HEADER_RX + record_map = { + "H": HeaderRecord, + "P": PatientRecord, + "O": OrderRecord, + "R": ResultRecord, + "C": CommentRecord, + "Q": RequestInformationRecord, + "M": ManufacturerInfoRecord, + "L": TerminatorRecord, + } + + def get_metadata(self, wrapper): + return {"version": VERSION, + "header_rx": HEADER_RX.pattern.decode()} + + +INSTRUMENT = HoribaPentraXLR() diff --git a/src/senaite/astm/instruments/horiba_yumizen_h5xx.py b/src/senaite/astm/instruments/horiba_yumizen_h5xx.py index 1f89dcf..5fab0c6 100644 --- a/src/senaite/astm/instruments/horiba_yumizen_h5xx.py +++ b/src/senaite/astm/instruments/horiba_yumizen_h5xx.py @@ -1,6 +1,10 @@ # -*- coding: utf-8 -*- +import re + from senaite.astm import records +from senaite.astm.core.instrument import Instrument +from senaite.astm.core.instrument import register_instrument from senaite.astm.fields import ComponentField from senaite.astm.fields import DateTimeField from senaite.astm.fields import NotUsedField @@ -10,34 +14,7 @@ VERSION = "1.0.0" # Supports H500 and H550 -HEADER_RX = r".*H5[0,5]0\^" - - -def get_metadata(wrapper): - """Additional metadata - - :param wrapper: The wrapper instance - :returns: dictionary of additional metadata - """ - return { - "version": VERSION, - "header_rx": HEADER_RX, - } - - -def get_mapping(): - """Returns the wrappers for this instrument - """ - return { - "H": HeaderRecord, - "P": PatientRecord, - "O": OrderRecord, - "R": ResultRecord, - "C": CommentRecord, - "Q": RequestInformationRecord, - "M": ManufacturerInfoRecord, - "L": TerminatorRecord, - } +HEADER_RX = re.compile(rb".*H5[0,5]0\^") class HeaderRecord(records.HeaderRecord): @@ -148,3 +125,26 @@ class ManufacturerInfoRecord(records.ManufacturerInfoRecord): class TerminatorRecord(records.TerminatorRecord): """Message Termination Record (L) """ + + +@register_instrument +class HoribaYumizenH5xx(Instrument): + name = "horiba_yumizen_h5xx" + header_regex = HEADER_RX + record_map = { + "H": HeaderRecord, + "P": PatientRecord, + "O": OrderRecord, + "R": ResultRecord, + "C": CommentRecord, + "Q": RequestInformationRecord, + "M": ManufacturerInfoRecord, + "L": TerminatorRecord, + } + + def get_metadata(self, wrapper): + return {"version": VERSION, + "header_rx": HEADER_RX.pattern.decode()} + + +INSTRUMENT = HoribaYumizenH5xx() diff --git a/src/senaite/astm/instruments/roche_cobas_c111.py b/src/senaite/astm/instruments/roche_cobas_c111.py index e6ad615..fed5c74 100644 --- a/src/senaite/astm/instruments/roche_cobas_c111.py +++ b/src/senaite/astm/instruments/roche_cobas_c111.py @@ -1,6 +1,10 @@ # -*- coding: utf-8 -*- +import re + from senaite.astm import records +from senaite.astm.core.instrument import Instrument +from senaite.astm.core.instrument import register_instrument from senaite.astm.fields import ComponentField from senaite.astm.fields import ConstantField from senaite.astm.fields import DateTimeField @@ -10,34 +14,7 @@ from senaite.astm.mapping import Component VERSION = "1.0.0" -HEADER_RX = r".*Roche\^c111" - - -def get_metadata(wrapper): - """Additional metadata - - :param wrapper: The wrapper instance - :returns: dictionary of additional metadata - """ - return { - "version": VERSION, - "header_rx": HEADER_RX, - } - - -def get_mapping(): - """Returns the wrappers for this instrument - """ - return { - "H": HeaderRecord, - "P": PatientRecord, - "O": OrderRecord, - "R": ResultRecord, - "C": CommentRecord, - "Q": RequestInformationRecord, - "M": ManufacturerInfoRecord, - "L": TerminatorRecord, - } +HEADER_RX = re.compile(rb".*Roche\^c111") class HeaderRecord(records.HeaderRecord): @@ -184,3 +161,26 @@ class ManufacturerInfoRecord(records.ManufacturerInfoRecord): class TerminatorRecord(records.TerminatorRecord): """Message Termination Record (L) """ + + +@register_instrument +class RocheCobasC111(Instrument): + name = "roche_cobas_c111" + header_regex = HEADER_RX + record_map = { + "H": HeaderRecord, + "P": PatientRecord, + "O": OrderRecord, + "R": ResultRecord, + "C": CommentRecord, + "Q": RequestInformationRecord, + "M": ManufacturerInfoRecord, + "L": TerminatorRecord, + } + + def get_metadata(self, wrapper): + return {"version": VERSION, + "header_rx": HEADER_RX.pattern.decode()} + + +INSTRUMENT = RocheCobasC111() diff --git a/src/senaite/astm/instruments/roche_cobas_c311.py b/src/senaite/astm/instruments/roche_cobas_c311.py index 082006b..39470cb 100644 --- a/src/senaite/astm/instruments/roche_cobas_c311.py +++ b/src/senaite/astm/instruments/roche_cobas_c311.py @@ -1,6 +1,10 @@ # -*- coding: utf-8 -*- +import re + from senaite.astm import records +from senaite.astm.core.instrument import Instrument +from senaite.astm.core.instrument import register_instrument from senaite.astm.fields import ComponentField from senaite.astm.fields import ConstantField from senaite.astm.fields import DateTimeField @@ -10,7 +14,7 @@ from senaite.astm.mapping import Component VERSION = "1.0.0" -HEADER_RX = r".*c311\^" +HEADER_RX = re.compile(rb".*c311\^") ABNORMAL_FLAGS = ["L", "H", "LL", "HH", "N", "A",] ACTION_CODES = ["N", "Q", "A", "C",] @@ -22,33 +26,6 @@ STATUS = ["F", "C",] -def get_metadata(wrapper): - """Additional metadata - - :param wrapper: The wrapper instance - :returns: dictionary of additional metadata - """ - return { - "version": VERSION, - "header_rx": HEADER_RX, - } - - -def get_mapping(): - """Returns the wrappers for this instrument - """ - return { - "H": HeaderRecord, - "P": PatientRecord, - "O": OrderRecord, - "R": ResultRecord, - "C": CommentRecord, - "Q": RequestInformationRecord, - "M": ManufacturerInfoRecord, - "L": TerminatorRecord, - } - - class HeaderRecord(records.HeaderRecord): """Message Header Record (H) """ @@ -170,3 +147,26 @@ class ManufacturerInfoRecord(records.ManufacturerInfoRecord): class TerminatorRecord(records.TerminatorRecord): """Message Termination Record (L) """ + + +@register_instrument +class RocheCobasC311(Instrument): + name = "roche_cobas_c311" + header_regex = HEADER_RX + record_map = { + "H": HeaderRecord, + "P": PatientRecord, + "O": OrderRecord, + "R": ResultRecord, + "C": CommentRecord, + "Q": RequestInformationRecord, + "M": ManufacturerInfoRecord, + "L": TerminatorRecord, + } + + def get_metadata(self, wrapper): + return {"version": VERSION, + "header_rx": HEADER_RX.pattern.decode()} + + +INSTRUMENT = RocheCobasC311() diff --git a/src/senaite/astm/instruments/spotchem_el.py b/src/senaite/astm/instruments/spotchem_el.py index 55e7a28..a1bc7e2 100644 --- a/src/senaite/astm/instruments/spotchem_el.py +++ b/src/senaite/astm/instruments/spotchem_el.py @@ -1,37 +1,38 @@ # -*- coding: utf-8 -*- +import re +from datetime import datetime + from senaite.astm import records +from senaite.astm import utils +from senaite.astm.constants import ENQ +from senaite.astm.constants import EOT +from senaite.astm.constants import NAK +from senaite.astm.core.instrument import Instrument +from senaite.astm.core.instrument import register_instrument from senaite.astm.fields import ComponentField from senaite.astm.fields import DateTimeField from senaite.astm.fields import TextField from senaite.astm.mapping import Component +from senaite.astm.utils import f as fmt +from senaite.astm.utils import u VERSION = "1.0.0" # Supports SE1520 -HEADER_RX = r".*SE-1520\^" - - -def get_metadata(wrapper): - """Additional metadata - - :param wrapper: The wrapper instance - :returns: dictionary of additional metadata - """ - return { - "version": VERSION, - "header_rx": HEADER_RX, - } - - -def get_mapping(): - """Returns the wrappers for this instrument - """ - return { - "H": HeaderRecord, - "O": OrderRecord, - "R": ResultRecord, - "L": TerminatorRecord, - } +HEADER_RX = re.compile(rb".*SE-1520\^") + +# Raw, non-ASTM wire format emitted by the SE-1520. +RAW_DATA_RX = re.compile( + rb"\x02" # Start of the message + rb"(\d{2}/\d{2}/\d{2})\s+" # Date in YY/MM/DD format + rb"(\d{2}:\d{2})\s+" # Time in HH:MM format + rb"ID#\s*([A-Z0-9\-_]+)\s+" # Sample ID prefixed by "ID#" + rb"\[(.*?)\]\s+" # Sample type in square brackets + rb"Na\s+([\d.]+)\s+(mmol/L)\s+" # Na result + rb"K\s+([\d.]+)\s+(mmol/L)\s+" # K result + rb"Cl\s+([\d.]+)\s+(mmol/L)" # Cl result + rb"\s*\x03" # End of the message +) class HeaderRecord(records.HeaderRecord): @@ -78,3 +79,78 @@ class ResultRecord(records.ResultRecord): class TerminatorRecord(records.TerminatorRecord): """Message Termination Record (L) """ + + +@register_instrument +class SpotchemEL(Instrument): + name = "spotchem_el" + header_regex = HEADER_RX + raw_data_regex = RAW_DATA_RX + record_map = { + "H": HeaderRecord, + "O": OrderRecord, + "R": ResultRecord, + "L": TerminatorRecord, + } + + def get_metadata(self, wrapper): + return {"version": VERSION, + "header_rx": HEADER_RX.pattern.decode()} + + def handle_raw_data(self, protocol, data): + """Synthesise a complete ASTM session from a single non-ASTM + packet emitted by the Spotchem SE-1520. + + Drives ``protocol`` directly: ENQ, queue the synthetic ASTM + frames, EOT. + """ + parts = re.match(RAW_DATA_RX, data) + if not parts: + return NAK + if not protocol.in_transfer_state: + protocol.on_enq(ENQ) + + date = parts.group(1).decode("utf-8") + time = parts.group(2).decode("utf-8") + sample_id = parts.group(3).decode("utf-8") + sample_type = parts.group(4).decode("utf-8") + na_result = float(parts.group(5)) + na_unit = parts.group(6).decode("utf-8") + k_result = float(parts.group(7)) + k_unit = parts.group(8).decode("utf-8") + cl_result = float(parts.group(9)) + cl_unit = parts.group(10).decode("utf-8") + + dt = datetime.strptime("%s %s" % (date, time), "%y/%m/%d %H:%M") + timestamp = dt.strftime("%Y%m%d%H%M%S") + + frames = [ + fmt( + "1H|\\^&|||SE-1520^Spotchem^1.0.0|||||||||{ts}{CR}{ETX}", + ts=timestamp), + fmt( + "2O|1|{sid}||{stype}|||{ts}||||||||||||||||||{CR}{ETX}", + sid=sample_id, stype=sample_type, ts=timestamp), + fmt( + "3R|1|Na|{result}|{unit}||||||||{ts}|{CR}{ETX}", + result=na_result, unit=na_unit, ts=timestamp), + fmt( + "4R|2|K|{result}|{unit}||||||||{ts}|{CR}{ETX}", + result=k_result, unit=k_unit, ts=timestamp), + fmt( + "5R|3|Cl|{result}|{unit}||||||||{ts}|{CR}{ETX}", + result=cl_result, unit=cl_unit, ts=timestamp), + fmt("6L|1|N{CR}{ETX}"), + ] + messages = [] + for frame in frames: + cs = utils.make_checksum(frame) + messages.append( + fmt("{STX}{frame}{cs}{CRLF}", frame=u(frame), cs=u(cs))) + + protocol.messages = messages + protocol.on_eot(EOT) + return None + + +INSTRUMENT = SpotchemEL() diff --git a/src/senaite/astm/instruments/sysmex_xn.py b/src/senaite/astm/instruments/sysmex_xn.py index f36eaa0..7d93793 100644 --- a/src/senaite/astm/instruments/sysmex_xn.py +++ b/src/senaite/astm/instruments/sysmex_xn.py @@ -1,6 +1,10 @@ # -*- coding: utf-8 -*- +import re + from senaite.astm import records +from senaite.astm.core.instrument import Instrument +from senaite.astm.core.instrument import register_instrument from senaite.astm.fields import ComponentField from senaite.astm.fields import DateField from senaite.astm.fields import DateTimeField @@ -16,7 +20,7 @@ # Supports XN-550, XN-530, XN-450, XN-430, XN-350, XN-330, XN-150, XN-110 # Sysmex Corporation, Automated Hematology Analyzer XN-L series ASTM Host # Interface Specifications, Revision 6, 2017 -HEADER_RX = r".*XN-(550|530|450|430|350|330|150|110)\^" +HEADER_RX = re.compile(rb".*XN-(550|530|450|430|350|330|150|110)\^") PATIENT_SEXES = ( "M", # M: male @@ -76,33 +80,6 @@ ) -def get_metadata(wrapper): - """Additional metadata - - :param wrapper: The wrapper instance - :returns: dictionary of additional metadata - """ - return { - "version": VERSION, - "header_rx": HEADER_RX, - } - - -def get_mapping(): - """Returns the wrappers for this instrument - """ - return { - "H": HeaderRecord, - "P": PatientRecord, - "O": OrderRecord, - "R": ResultRecord, - "C": CommentRecord, - "Q": RequestInformationRecord, - "M": ManufacturerInfoRecord, - "L": TerminatorRecord, - } - - class HeaderRecord(records.HeaderRecord): """Message Header Record (H) """ @@ -231,3 +208,26 @@ class ManufacturerInfoRecord(records.ManufacturerInfoRecord): class TerminatorRecord(records.TerminatorRecord): """Message Termination Record (L) """ + + +@register_instrument +class SysmexXN(Instrument): + name = "sysmex_xn" + header_regex = HEADER_RX + record_map = { + "H": HeaderRecord, + "P": PatientRecord, + "O": OrderRecord, + "R": ResultRecord, + "C": CommentRecord, + "Q": RequestInformationRecord, + "M": ManufacturerInfoRecord, + "L": TerminatorRecord, + } + + def get_metadata(self, wrapper): + return {"version": VERSION, + "header_rx": HEADER_RX.pattern.decode()} + + +INSTRUMENT = SysmexXN() diff --git a/src/senaite/astm/instruments/sysmex_xp.py b/src/senaite/astm/instruments/sysmex_xp.py index 8fa1a5b..3cb9942 100644 --- a/src/senaite/astm/instruments/sysmex_xp.py +++ b/src/senaite/astm/instruments/sysmex_xp.py @@ -1,6 +1,10 @@ # -*- coding: utf-8 -*- +import re + from senaite.astm import records +from senaite.astm.core.instrument import Instrument +from senaite.astm.core.instrument import register_instrument from senaite.astm.fields import ComponentField from senaite.astm.fields import ConstantField from senaite.astm.fields import DateTimeField @@ -16,7 +20,7 @@ # Supports XP-100 or XP-300 # Sysmex Corporation, XP Series ASTM Communication Specifications (ASTM # E1394-97, E1381-02/94), Revision 1.0, 2012 -HEADER_RX = r".*XP-(100|300)\^" +HEADER_RX = re.compile(rb".*XP-(100|300)\^") SAMPLE_ID_ATTRIBUTES = ( "M", # M: Manual input @@ -44,33 +48,6 @@ ) -def get_metadata(wrapper): - """Additional metadata - - :param wrapper: The wrapper instance - :returns: dictionary of additional metadata - """ - return { - "version": VERSION, - "header_rx": HEADER_RX, - } - - -def get_mapping(): - """Returns the wrappers for this instrument - """ - return { - "H": HeaderRecord, - "P": PatientRecord, - "O": OrderRecord, - "R": ResultRecord, - "C": CommentRecord, - "Q": RequestInformationRecord, - "M": ManufacturerInfoRecord, - "L": TerminatorRecord, - } - - class HeaderRecord(records.HeaderRecord): """Message Header Record (H) """ @@ -165,3 +142,26 @@ class ManufacturerInfoRecord(records.ManufacturerInfoRecord): class TerminatorRecord(records.TerminatorRecord): """Message Termination Record (L) """ + + +@register_instrument +class SysmexXP(Instrument): + name = "sysmex_xp" + header_regex = HEADER_RX + record_map = { + "H": HeaderRecord, + "P": PatientRecord, + "O": OrderRecord, + "R": ResultRecord, + "C": CommentRecord, + "Q": RequestInformationRecord, + "M": ManufacturerInfoRecord, + "L": TerminatorRecord, + } + + def get_metadata(self, wrapper): + return {"version": VERSION, + "header_rx": HEADER_RX.pattern.decode()} + + +INSTRUMENT = SysmexXP() diff --git a/src/senaite/astm/interfaces.py b/src/senaite/astm/interfaces.py deleted file mode 100644 index 321ec14..0000000 --- a/src/senaite/astm/interfaces.py +++ /dev/null @@ -1,15 +0,0 @@ -# -*- coding: utf-8 -*- -from zope.interface import Interface - - -class IDataHandler(Interface): - """Handle custom data - """ - - def can_handle(): - """Checks if the adapter can handle the data - """ - - def handle_data(data): - """Handle the received data. - """ diff --git a/src/senaite/astm/lims.py b/src/senaite/astm/lims.py deleted file mode 100644 index 41e31e7..0000000 --- a/src/senaite/astm/lims.py +++ /dev/null @@ -1,127 +0,0 @@ -# -*- coding: utf-8 -*- - -from time import sleep - -import requests - -from senaite.astm import logger - -# SENAITE.JSONAPI route -API_BASE_URL = "@@API/senaite/v1" - - -def post_to_senaite(messages, session, **kwargs): - """POST ASTM messages to SENAITE - """ - attempt = 1 - retries = kwargs.get('retries', 3) - delay = kwargs.get('delay', 5) - consumer = kwargs.get('consumer', 'senaite.lis2a.import') - success = False - - while True: - # Open a session with SENAITE and authenticate - authenticated = session.auth() - # Build the POST payload - payload = { - 'consumer': consumer, - 'messages': messages, - } - if authenticated: - # Send the message - response = session.post('push', payload) - success = response.get('success') - if success: - break - - # the break here ensures that at least one time is tried - if attempt >= retries: - break - - # increase attempts - attempt += 1 - - logger.warn('Could not push. Retrying {}/{}'.format( - attempt, retries)) - - # Sleep before we retry - sleep(delay) - - if not success: - logger.error('Could not push the message') - - -class Session(object): - """SENAITE Request Session - """ - - def __init__(self, url, **kw): - auth = requests.utils.get_auth_from_url(url) - self.username = auth[0] - self.password = auth[1] - self.url = requests.utils.urldefragauth(url) - - @property - def session(self): - session = requests.Session() - session.auth = (self.username, self.password) - return session - - def auth(self): - logger.info("Starting session with SENAITE ...") - - # try to get the version of the remote JSON API - version = self.get("version") - if not version or not version.get("version"): - logger.error("senaite.jsonapi not found on at {}".format(self.url)) - return False - - # try to get the current logged in user - user = self.get("users/current") - user = user.get("items", [{}])[0] - if not user or user.get("authenticated") is False: - logger.error("Wrong username/password") - return False - - logger.info("Session established ('{}') with '{}'" - .format(self.username, self.url)) - return True - - def post(self, endpoint, payload): - """Sends a POST request to SENAITE - """ - url = self.get_url(endpoint) - try: - response = self.session.post(url, data=payload) - except Exception as e: - message = "Could not send POST to {}".format(url) - logger.error(message) - logger.error(e) - return {} - - return response.json() - - def get(self, endpoint, timeout=60): - """Fetch the given url or endpoint and return a parsed JSON object - """ - url = self.get_url(endpoint) - try: - response = self.session.get(url, timeout=timeout) - except Exception as e: - message = "Could not connect to {}".format(url) - logger.error(message) - logger.error(e) - return {} - - status = response.status_code - if status != 200: - message = "GET for {} returned {}".format(endpoint, status) - logger.error(message) - return {} - - return response.json() - - def get_url(self, endpoint): - """Create an API URL from an endpoint or absolute url - """ - return "{}/{}/{}".format(self.url, API_BASE_URL, endpoint) diff --git a/src/senaite/astm/mapping.py b/src/senaite/astm/mapping.py index c9f8b94..1335d0c 100644 --- a/src/senaite/astm/mapping.py +++ b/src/senaite/astm/mapping.py @@ -3,15 +3,11 @@ # Credits to Alexander Shorin: # https://github.com/kxepal/python-astm +from itertools import zip_longest from operator import itemgetter from senaite.astm.fields import Field -try: - from itertools import izip_longest -except ImportError: # Python 3 - from itertools import zip_longest as izip_longest - class MetaMapping(type): """Metaclass for record mappings @@ -50,7 +46,7 @@ class Mapping(_MappingProxy): def __init__(self, *args, **kwargs): fieldnames = map(itemgetter(0), self._fields) - values = dict(izip_longest(fieldnames, args)) + values = dict(zip_longest(fieldnames, args)) values.update(kwargs) self._data = {} for attrname, field in self._fields: diff --git a/src/senaite/astm/protocol.py b/src/senaite/astm/protocol.py deleted file mode 100644 index bd881ae..0000000 --- a/src/senaite/astm/protocol.py +++ /dev/null @@ -1,261 +0,0 @@ -# -*- coding: utf-8 -*- - -import asyncio -import os - -from senaite.astm import adapter_registry -from senaite.astm import logger -from senaite.astm.constants import ACK -from senaite.astm.constants import ENQ -from senaite.astm.constants import EOT -from senaite.astm.constants import NAK -from senaite.astm.constants import STX -from senaite.astm.exceptions import InvalidState -from senaite.astm.exceptions import NotAccepted -from senaite.astm.interfaces import IDataHandler -from senaite.astm.utils import is_chunked_message -from senaite.astm.utils import join -from senaite.astm.utils import validate_checksum -from senaite.astm.utils import write_message -from senaite.astm.wrapper import Wrapper - -TIMEOUT = 15 -QUEUE = asyncio.Queue() -DEFAULT_FORMAT = "json" - - -class ASTMProtocol(asyncio.Protocol): - """ASTM Protocol - - Responsible for communication and collecting complete and valid messages. - - NOTE: Every connection must be handled by an own instance of this protocol! - """ - def __init__(self, **kwargs): - logger.debug("ASTMProtocol:constructor") - self.loop = asyncio.get_event_loop() - self.queue = kwargs.get("queue", QUEUE) - self.timeout = kwargs.get("timeout", TIMEOUT) - self.message_format = kwargs.get("message_format", DEFAULT_FORMAT) - - self.transport = None - self.client = None - self.timer = None - self.chunks = [] - self.messages = [] - self.in_transfer_state = False - - def connection_made(self, transport): - """Called when a connection is made. - """ - self.transport = transport - # Remember the connected client - self.client = self.get_client_key(transport) - logger.debug("Connection from {!s}".format(self.client)) - - def start_timer(self): - """Start the timeout timer - """ - # Closes the connection if no data was received after the given timeout - self.timer = self.loop.call_later(self.timeout, self.on_timeout) - - def cancel_timer(self): - """Cancel the timeout timer - """ - if self.timer is None: - return - self.timer.cancel() - - def restart_timer(self): - """Restart the timeout timer - """ - self.cancel_timer() - self.start_timer() - - def get_client_key(self, transport): - """Return the client key for the given transport - """ - peername = transport.get_extra_info("peername") - return "{:s}:{:d}".format(*peername) - - def close_connection(self): - """Cleanup and close connection - """ - self.discard_env() - self.transport.close() - - def discard_chunked_messages(self): - """Flush chunked messages - """ - self.chunks = [] - - def discard_env(self): - """Flush environment - """ - self.chunks = [] - self.messages = [] - self.in_transfer_state = False - - def data_received(self, data): - """Called when some data is received. - """ - logger.debug("-> Data received from {!s}: {!r}".format( - self.client, data)) - - # restart the timer - # -> this ensures the next data is received within the timeout - self.restart_timer() - - # handle the data - response = self.handle_data(data) - if response is not None: - logger.debug("<- Sending response: {!r}".format(response)) - self.transport.write(response) - - def handle_data(self, data): - """Process incoming data - """ - # lookup custom multi-adapter to handle the data - adapters = adapter_registry.getAdapters((self, data), IDataHandler) - for name, adapter in adapters: - if adapter and adapter.can_handle(): - return adapter.handle_data() - - response = None - if data.startswith(ENQ): - response = self.on_enq(data) - elif data.startswith(ACK): - response = self.on_ack(data) - elif data.startswith(NAK): - response = self.on_nak(data) - elif data.startswith(EOT): - response = self.on_eot(data) - elif data.startswith(STX): - response = self.on_message(data) - else: - response = self.default_handler(data) - return response - - def default_handler(self, data): - """Default callback - """ - # raise ValueError("Unable to dispatch data: %r", data) - logger.error("Unable to dispatch data: %r", data) - - def on_enq(self, data): - """Callback when was received - """ - logger.debug("on_enq: %r", data) - if not self.in_transfer_state: - self.in_transfer_state = True - return ACK - else: - logger.error("ENQ is not expected") - return NAK - - def on_ack(self, data): - """Calls on message receiving.""" - logger.debug("on_ack: %r", data) - raise NotAccepted("Server should not be ACKed.") - - def on_nak(self, data): - """Calls on message receiving.""" - logger.debug("on_nak: %r", data) - raise NotAccepted("Server should not be NAKed.") - - def on_eot(self, data): - """Calls on message receiving.""" - logger.debug("on_eot: %r", data) - - if not self.in_transfer_state: - self.close_connection() - raise InvalidState("Server is not ready to accept EOT message.") - - # stop any running timer - self.cancel_timer() - - # XXX: Seen from Yumizen H550: EOT right after ENQ. - # Maybe this is some kind of keepalive? - if not self.messages: - self.discard_env() - return - - # Wrap the message - wrapper = Wrapper(self.messages) - - if self.message_format == "astm": - self.queue.put_nowait(wrapper.to_astm()) - elif self.message_format == "json": - self.queue.put_nowait(wrapper.to_json()) - else: - self.queue.put_nowait(wrapper.to_lis2a()) - - # Store the raw message for debugging and development purposes - self.log_message(wrapper.to_astm()) - - # Drop session - self.discard_env() - - def log_message(self, message, directory="astm_messages"): - """Store the raw ASTM message if the folder exists in the CWD - """ - cwd = os.getcwd() - path = os.path.join(cwd, directory) - if os.path.exists(path): - write_message(message, path) - - def on_timeout(self): - """Callback for timeout event - """ - logger.warning("Connection for {!r} timed out after {!r}s: Closing..." - .format(self.client, self.timeout)) - self.close_connection() - - def on_message(self, data): - """Callback when a message was received - """ - logger.debug("on_message: %r", data) - if not self.in_transfer_state: - self.discard_chunked_messages() - return NAK - else: - try: - self.handle_message(data) - return ACK - except Exception as exc: - logger.error("Error occurred on message handling. {!r}" - .format(exc)) - return NAK - - def handle_message(self, message): - """Handle message data - """ - - full_message = None - is_chunked_transfer = is_chunked_message(message) - - # message is splitted - if is_chunked_transfer: - self.chunks.append(message) - # join splitted message - elif self.chunks: - self.chunks.append(message) - full_message = join(self.chunks) - self.discard_chunked_messages() - else: - full_message = message - - # message not yet complete - if not full_message: - return - - if not validate_checksum(full_message): - raise NotAccepted("Checksum failed for '%r'" % full_message) - - self.messages.append(full_message) - - def connection_lost(self, ex): - """Called when the connection is lost or closed. - """ - logger.warning("Lost connection for {!s}".format(self.client)) - self.close_connection() diff --git a/src/senaite/astm/sender.py b/src/senaite/astm/sender.py index 031e213..01e9228 100644 --- a/src/senaite/astm/sender.py +++ b/src/senaite/astm/sender.py @@ -3,9 +3,9 @@ import argparse import logging -from senaite.astm import lims from senaite.astm import logger -from senaite.astm.lims import post_to_senaite +from senaite.astm.core import lims +from senaite.astm.core.lims import post_to_senaite def main(): diff --git a/src/senaite/astm/server.py b/src/senaite/astm/server.py deleted file mode 100644 index 62f8fb1..0000000 --- a/src/senaite/astm/server.py +++ /dev/null @@ -1,200 +0,0 @@ -# -*- coding: utf-8 -*- - -import argparse -import asyncio -import contextlib -import logging -import os -import sys - -from senaite.astm import lims -from senaite.astm import logger -from senaite.astm.lims import post_to_senaite -from senaite.astm.protocol import ASTMProtocol -from senaite.astm.utils import write_message - -LOGFILE = "senaite-astm-server.log" - - -async def consume(queue, callback=None): - """ASTM Message consumer coroutine function - """ - while True: - message = await queue.get() - if callable(callback): - callback(message) - - -def main(): - # Argument parser - parser = argparse.ArgumentParser( - formatter_class=argparse.ArgumentDefaultsHelpFormatter) - - # Argument groups - astm_group = parser.add_argument_group('ASTM SERVER') - lims_group = parser.add_argument_group('SENAITE LIMS') - - astm_group.add_argument( - '-l', - '--listen', - type=str, - default='0.0.0.0', - help='Listen IP address') - - astm_group.add_argument( - '-p', - '--port', - type=str, - default='4010', - help='Port to connect') - - astm_group.add_argument( - '-o', - '--output', - type=str, - help='Output directory to write full messages') - - lims_group.add_argument( - '-u', - '--url', - type=str, - help='SENAITE URL address including username and password in the ' - 'format: http(s)://:@') - - lims_group.add_argument( - '-c', - '--consumer', - type=str, - default='senaite.core.lis2a.import', - help='SENAITE push consumer interface') - - lims_group.add_argument( - '-m', - '--message-format', - type=str, - default='json', - help='Message format to send to SENAITE. ' - 'Allowed formats: "astm", "lis2a", "json".') - - lims_group.add_argument( - '-r', - '--retries', - type=int, - default=3, - help='Number of attempts of reconnection when SENAITE ' - 'instance is not reachable. Only has effect when ' - 'argument --url is set') - - lims_group.add_argument( - '-d', - '--delay', - type=int, - default=5, - help='Time delay in seconds between retries when ' - 'SENAITE instance is not reachable. Only has ' - 'effect when argument --url is set') - - parser.add_argument( - '-v', - '--verbose', - action='store_true', - help='Verbose logging') - - parser.add_argument( - '--logfile', - default=LOGFILE, - help='Path to store log files') - - # Parse Arguments - args = parser.parse_args() - - if args.logfile: - handler = logging.handlers.RotatingFileHandler( - args.logfile, maxBytes=5, backupCount=0) - # Format each log message like this - formatter = logging.Formatter( - '%(asctime)s %(levelname)-8s %(message)s') - # Attach the formatter to the handler - handler.setFormatter(formatter) - # Attach the handler to the logger - logger.addHandler(handler) - - # Get the current event loop. - loop = asyncio.get_event_loop() - - # Set logging - if args.verbose: - logger.setLevel(logging.DEBUG) - else: - logger.setLevel(logging.INFO) - logger.addHandler(logging.StreamHandler()) - - # Validate output path - output = args.output - if output and not os.path.isdir(args.output): - logger.error('Output path must be an existing directory') - return sys.exit(-1) - - # Validate SENAITE URL - url = args.url - if url: - session = lims.Session(url) - logger.info('Checking connection to SENAITE ...') - if not session.auth(): - return sys.exit(-1) - - def dispatch_astm_message(message): - """Dispatch astm message - """ - logger.debug('Dispatching ASTM Message') - if output: - path = os.path.abspath(output) - loop.create_task( - asyncio.to_thread( - write_message, message, path)) - if url: - session = lims.Session(url) - session_args = { - 'delay': args.delay, - 'retries': args.retries, - 'consumer': args.consumer, - } - loop.create_task( - asyncio.to_thread( - post_to_senaite, message, session, **session_args)) - - # Create a ASTM message consumer task to be scheduled concurrently. - queue = asyncio.Queue() - loop.create_task(consume(queue, callback=dispatch_astm_message)) - - # Create a TCP server coroutine listening on port of the host address. - # IMPORTANT: We create a new Protocol for every connection! - server_coro = loop.create_server( - lambda: ASTMProtocol(queue=queue, message_format=args.message_format), - host=args.listen, port=args.port) - - # Run until the future (an instance of Future) has completed. - server = loop.run_until_complete(server_coro) - - for socket in server.sockets: - ip, port = socket.getsockname() - logger.info('Starting server on {}:{}'.format(ip, port)) - logger.info('ASTM server ready to handle connections ...') - - try: - loop.run_forever() - except KeyboardInterrupt: - logger.info('Shutting down server...') - all_tasks = asyncio.gather( - *asyncio.all_tasks(loop), return_exceptions=True) - all_tasks.cancel() - with contextlib.suppress(asyncio.CancelledError): - loop.run_until_complete(all_tasks) - loop.run_until_complete(loop.shutdown_asyncgens()) - finally: - loop.close() - logger.info('Server is now down...') - - -if __name__ == '__main__': - main() diff --git a/src/senaite/astm/tests/data/envelopes/abbott_afinion2.json b/src/senaite/astm/tests/data/envelopes/abbott_afinion2.json index 7b9415d..7a14fca 100644 --- a/src/senaite/astm/tests/data/envelopes/abbott_afinion2.json +++ b/src/senaite/astm/tests/data/envelopes/abbott_afinion2.json @@ -1,4 +1,5 @@ { + "C": [], "H": [ { "address": null, @@ -37,6 +38,7 @@ "type": "L" } ], + "M": [], "O": [ { "action_code": "N", @@ -119,6 +121,7 @@ "weight": null } ], + "Q": [], "R": [ { "abnormal_flag": null, @@ -144,6 +147,10 @@ ], "metadata": { "astm": "\u00021H|\\^&|||Afinion 2 Analyzer^^AF20052397|||||||P|1|20241206141235\rP|1||3643|||||U\rO|1||5|^^^HbA1c|||||||N||||^O||||||||^10228413||F\rR|1|^^^HbA1c|5.9|%||||F||3643||20241206140615\rL|1|N\r\u0003F2\r", - "lis2a": "H|\\^&|||Afinion 2 Analyzer^^AF20052397|||||||P|1|20241206141235\rP|1||3643|||||U\rO|1||5|^^^HbA1c|||||||N||||^O||||||||^10228413||F\rR|1|^^^HbA1c|5.9|%||||F||3643||20241206140615\rL|1|N\r\u0003" + "envelope_version": "1.1", + "header_rx": ".*Afinion 2 Analyzer\\^", + "hl7": "", + "lis2a": "H|\\^&|||Afinion 2 Analyzer^^AF20052397|||||||P|1|20241206141235\rP|1||3643|||||U\rO|1||5|^^^HbA1c|||||||N||||^O||||||||^10228413||F\rR|1|^^^HbA1c|5.9|%||||F||3643||20241206140615\rL|1|N\r\u0003", + "version": "1.0.0" } } \ No newline at end of file diff --git a/src/senaite/astm/tests/data/envelopes/cobas_c111.json b/src/senaite/astm/tests/data/envelopes/cobas_c111.json index 2ada3ec..4b32d1c 100644 --- a/src/senaite/astm/tests/data/envelopes/cobas_c111.json +++ b/src/senaite/astm/tests/data/envelopes/cobas_c111.json @@ -155,6 +155,7 @@ "weight": null } ], + "Q": [], "R": [ { "abnormal_flag": "N", @@ -183,6 +184,10 @@ ], "metadata": { "astm": "\u00021H|\\^&|||SENAITE^Roche^c111^4.2.2.1730^1^13147|||||host|RSUPL^REAL|P|1|20230803131713\r\u0017C6\n\u00022P|1||\r\u00174B\n\u00023O|1||T20 10134GA D28^^6||S||||||N|||||||||||20230803131713|||F\r\u0017B3\n\u00024R|1|^^^413|40.13|g/L||N||F||$SYS$||20230803131700\r\u0017CE\n\u00025C|1|I||I\r\u00174F\n\u00026M|1|RR^BM^c111^1|-21|-21\\-21\\1\\1\\1\\-1\\-33\\-37\\-38\\-38\\-42\\-42\\-42\\-41\\-42\\-43\\140\\141|0.018514\r\u0017FD\n\u00027L|1|N\r\u00030A", - "lis2a": "H|\\^&|||SENAITE^Roche^c111^4.2.2.1730^1^13147|||||host|RSUPL^REAL|P|1|20230803131713\r\u0017P|1||\r\u0017O|1||T20 10134GA D28^^6||S||||||N|||||||||||20230803131713|||F\r\u0017R|1|^^^413|40.13|g/L||N||F||$SYS$||20230803131700\r\u0017C|1|I||I\r\u0017M|1|RR^BM^c111^1|-21|-21\\-21\\1\\1\\1\\-1\\-33\\-37\\-38\\-38\\-42\\-42\\-42\\-41\\-42\\-43\\140\\141|0.018514\r\u0017L|1|N\r\u0003" + "envelope_version": "1.1", + "header_rx": ".*Roche\\^c111", + "hl7": "", + "lis2a": "H|\\^&|||SENAITE^Roche^c111^4.2.2.1730^1^13147|||||host|RSUPL^REAL|P|1|20230803131713\r\u0017P|1||\r\u0017O|1||T20 10134GA D28^^6||S||||||N|||||||||||20230803131713|||F\r\u0017R|1|^^^413|40.13|g/L||N||F||$SYS$||20230803131700\r\u0017C|1|I||I\r\u0017M|1|RR^BM^c111^1|-21|-21\\-21\\1\\1\\1\\-1\\-33\\-37\\-38\\-38\\-42\\-42\\-42\\-41\\-42\\-43\\140\\141|0.018514\r\u0017L|1|N\r\u0003", + "version": "1.0.0" } } \ No newline at end of file diff --git a/src/senaite/astm/tests/data/envelopes/cobas_c311.json b/src/senaite/astm/tests/data/envelopes/cobas_c311.json index 00bafee..4a7124c 100644 --- a/src/senaite/astm/tests/data/envelopes/cobas_c311.json +++ b/src/senaite/astm/tests/data/envelopes/cobas_c311.json @@ -90,6 +90,7 @@ "type": "L" } ], + "M": [], "O": [ { "action_code": "N", @@ -229,6 +230,7 @@ "weight": null } ], + "Q": [], "R": [ { "abnormal_flag": "A", @@ -387,6 +389,10 @@ ], "metadata": { "astm": "\u00021H|\\^&|||c311^1|||||host|RSUPL^REAL|P|1\rP|1\rO|1|11625^CL-PL-24-0370 ^1^^004|R1|^^^685/\\^^^687/\\^^^712/\\^^^158/\\^^^735/\\^^^717/\\^^^690/|R||||||N||^^||SC|||labo | ^ ^ ^ ^ |||20240203132011|||F\rR|1|^^^685/|22.4|U/l||A||F|||||P1\rC|1|I|43|I\rR|2|^^^687/|15.0|U/l||N||F|||||P1\rC|1|I|0|I\rR|3|^^^712/|4.1|umol/l||L||F|||||P1\rC|1|I|0|I\rR|4|^^^158/|301|U/l||N||F|||||P1\rC|1|I|0|I\rR|5|^^^735/|1.6|umol/l||N||F|||||P1\rC|1|I|0|I\rR|6|^^^717/|5.85|mmol/l||N||F|||||P1\rC|1|I|0|I\rR|7|^^^690/|34|umol/l||A||F|||||P1\rC|1|I|43|I\rL|1|N\r\u000306\r", - "lis2a": "H|\\^&|||c311^1|||||host|RSUPL^REAL|P|1\rP|1\rO|1|11625^CL-PL-24-0370 ^1^^004|R1|^^^685/\\^^^687/\\^^^712/\\^^^158/\\^^^735/\\^^^717/\\^^^690/|R||||||N||^^||SC|||labo | ^ ^ ^ ^ |||20240203132011|||F\rR|1|^^^685/|22.4|U/l||A||F|||||P1\rC|1|I|43|I\rR|2|^^^687/|15.0|U/l||N||F|||||P1\rC|1|I|0|I\rR|3|^^^712/|4.1|umol/l||L||F|||||P1\rC|1|I|0|I\rR|4|^^^158/|301|U/l||N||F|||||P1\rC|1|I|0|I\rR|5|^^^735/|1.6|umol/l||N||F|||||P1\rC|1|I|0|I\rR|6|^^^717/|5.85|mmol/l||N||F|||||P1\rC|1|I|0|I\rR|7|^^^690/|34|umol/l||A||F|||||P1\rC|1|I|43|I\rL|1|N\r\u0003" + "envelope_version": "1.1", + "header_rx": ".*c311\\^", + "hl7": "", + "lis2a": "H|\\^&|||c311^1|||||host|RSUPL^REAL|P|1\rP|1\rO|1|11625^CL-PL-24-0370 ^1^^004|R1|^^^685/\\^^^687/\\^^^712/\\^^^158/\\^^^735/\\^^^717/\\^^^690/|R||||||N||^^||SC|||labo | ^ ^ ^ ^ |||20240203132011|||F\rR|1|^^^685/|22.4|U/l||A||F|||||P1\rC|1|I|43|I\rR|2|^^^687/|15.0|U/l||N||F|||||P1\rC|1|I|0|I\rR|3|^^^712/|4.1|umol/l||L||F|||||P1\rC|1|I|0|I\rR|4|^^^158/|301|U/l||N||F|||||P1\rC|1|I|0|I\rR|5|^^^735/|1.6|umol/l||N||F|||||P1\rC|1|I|0|I\rR|6|^^^717/|5.85|mmol/l||N||F|||||P1\rC|1|I|0|I\rR|7|^^^690/|34|umol/l||A||F|||||P1\rC|1|I|43|I\rL|1|N\r\u0003", + "version": "1.0.0" } } \ No newline at end of file diff --git a/src/senaite/astm/tests/data/envelopes/dca_vantage.json b/src/senaite/astm/tests/data/envelopes/dca_vantage.json index 892e303..2ceaabc 100644 --- a/src/senaite/astm/tests/data/envelopes/dca_vantage.json +++ b/src/senaite/astm/tests/data/envelopes/dca_vantage.json @@ -53,6 +53,7 @@ "type": "L" } ], + "M": [], "O": [ { "action_code": null, @@ -133,6 +134,7 @@ "weight": null } ], + "Q": [], "R": [ { "abnormal_flag": null, @@ -200,6 +202,10 @@ ], "metadata": { "astm": "\u00021H|\\^&|||DCA VANTAGE^04.04.00.00^S067337|||||||P||20240820151746\rP|1|BU24R554\rO|1||660^0090||||||||||||||||||||||F\rR|1|^^^Alb|63.7|mg/L||||F|||20240820151030\rC|1|I|1.000^0.0 mg/L|G\rR|2|^^^Crt|230.8|mg/dL||||F|||20240820151030\rC|1|I|1.000^0.0 mg/dL|G\rR|3|^^^Ratio|27.6|mg/g||||F|||20240820151030\rL|1|N\r\u000343\r", - "lis2a": "H|\\^&|||DCA VANTAGE^04.04.00.00^S067337|||||||P||20240820151746\rP|1|BU24R554\rO|1||660^0090||||||||||||||||||||||F\rR|1|^^^Alb|63.7|mg/L||||F|||20240820151030\rC|1|I|1.000^0.0 mg/L|G\rR|2|^^^Crt|230.8|mg/dL||||F|||20240820151030\rC|1|I|1.000^0.0 mg/dL|G\rR|3|^^^Ratio|27.6|mg/g||||F|||20240820151030\rL|1|N\r\u0003" + "envelope_version": "1.1", + "header_rx": ".*(DCA VANTAGE|DCA Vantage)\\^", + "hl7": "", + "lis2a": "H|\\^&|||DCA VANTAGE^04.04.00.00^S067337|||||||P||20240820151746\rP|1|BU24R554\rO|1||660^0090||||||||||||||||||||||F\rR|1|^^^Alb|63.7|mg/L||||F|||20240820151030\rC|1|I|1.000^0.0 mg/L|G\rR|2|^^^Crt|230.8|mg/dL||||F|||20240820151030\rC|1|I|1.000^0.0 mg/dL|G\rR|3|^^^Ratio|27.6|mg/g||||F|||20240820151030\rL|1|N\r\u0003", + "version": "1.0.0" } } \ No newline at end of file diff --git a/src/senaite/astm/tests/data/envelopes/genexpert.json b/src/senaite/astm/tests/data/envelopes/genexpert.json index 2f7f688..67a1a80 100644 --- a/src/senaite/astm/tests/data/envelopes/genexpert.json +++ b/src/senaite/astm/tests/data/envelopes/genexpert.json @@ -72,6 +72,7 @@ "type": "L" } ], + "M": [], "O": [ { "action_code": null, @@ -152,6 +153,7 @@ "weight": null } ], + "Q": [], "R": [ { "abnormal_flag": null, @@ -3096,6 +3098,10 @@ ], "metadata": { "astm": "\u00021H|@^\\|URM-8lT4abZA-06||.806149 Happy Hospital^GeneXpert^4.8|||||HNH-SENAITE||P|1394-97|20250516125515\rP|1||||^^^^|||||||||||||||||||||\rO|1|PR25A137||^^^MTB-RIF|R|20250514121638|||||||||ORH||||||||||F\rR|1|^MTB-RIF^^Xpert^Xpert MTB-RIF Ultra^4^MTB^|NOT DETECTED^|||||F||John Doe|20250514121638|20250514132103|Cepheid-44413S0^806149^653624^831583371^56401^20250525\rC|1|I|Notes^^Id# 118176 Sardani P Lantion CDU/Dr. A Marcil cp|I\rR|2|^MTB-RIF^^Xpert^^^rpoB1^|INVALID^|||\rR|3|^MTB-RIF^^Xpert^^^rpoB1^Ct|^0.0|||\rR|4|^MTB-RIF^^Xpert^^^rpoB1^EndPt|^-2.0|||\rR|5|^MTB-RIF^^Xpert^^^rpoB2^|INVALID^|||\rR|6|^MTB-RIF^^Xpert^^^rpoB2^Ct|^0.0|||\rR|7|^MTB-RIF^^Xpert^^^rpoB2^EndPt|^4.0|||\rR|8|^MTB-RIF^^Xpert^^^rpoB3^|INVALID^|||\rR|9|^MTB-RIF^^Xpert^^^rpoB3^Ct|^0.0|||\rR|10|^MTB-RIF^^Xpert^^^rpoB3^EndPt|^8.0|||\rR|11|^MTB-RIF^^Xpert^^^rpoB4^|INVALID^|||\rR|12|^MTB-RIF^^Xpert^^^rpoB4^Ct|^0.0|||\rR|13|^MTB-RIF^^Xpert^^^rpoB4^EndPt|^8.0|||\rR|14|^MTB-RIF^^Xpert^^^SPC^|PASS^|||\rR|15|^MTB-RIF^^Xpert^^^SPC^Ct|^24.7|||\rR|16|^MTB-RIF^^Xpert^^^SPC^EndPt|^159.0|||\rR|17|^MTB-RIF^^Xpert^^^^|FAIL^|||\rR|18|^MTB-RIF^^Xpert^^^IS1081-IS6110^Ct|^0.0|||\rR|19|^MTB-RIF^^Xpert^^^IS1081-IS6110^EndPt|^3.0|||\rR|20|^MTB-RIF^^Trace^Xpert MTB-RIF Ultra^4^MTB Trace^|^|||||F||John Doe|20250514121638|20250514132103|Cepheid-44413S0^806149^653624^831583371^56401^20250525\rC|1|I|Notes^^Id# 118176 Sardani P Lantion CDU/Dr. A Marcil cp|I\rR|21|^MTB-RI^^Trace^^^rpoB1^|INVALID^|||\rR|22|^MTB-RIF^^Trace^^^rpoB1^Ct|^0.0|||\rR|23|^MTB-RIF^^Trace^^^rpoB1^EndPt|^-2.0|||\rR|24|^MTB-RIF^^Trace^^^rpoB2^|INVALID^|||\rR|25|^MTB-RIF^^Trace^^^rpoB2^Ct|^0.0|||\rR|26|^MTB-RIF^^Trace^^^rpoB2^EndPt|^4.0|||\rR|27|^MTB-RIF^^Trace^^^rpoB3^|INVALID^|||\rR|28|^MTB-RIF^^Trace^^^rpoB3^Ct|^0.0|||\rR|29|^MTB-RIF^^Trace^^^rpoB3^EndPt|^8.0|||\rR|30|^MTB-RIF^^Trace^^^rpoB4^|INVALID^|||\rR|31|^MTB-RIF^^Trace^^^rpoB4^Ct|^0.0|||\rR|32|^MTB-RIF^^Trace^^^rpoB4^EndPt|^8.0|||\rR|33|^MTB-RIF^^Trace^^^SPC^|PASS^|||\rR|34|^MTB-RIF^^Trace^^^SPC^Ct|^24.7|||\rR|35|^MTB-RIF^^Trace^^^SPC^EndPt|^159.0|||\rR|36|^MTB-RIF^^Trace^^^IS1081-IS6110^|FAIL^|||\rR|37|^MTB-RIF^^Trace^^^IS1081-IS6110^Ct|^0.0|||\rR|38|^MTB-RIF^^Trace^^^IS1081-IS6110^EndPt|^3.0|||\rR|39|^MTB-RIF^^RIF^Xpert MTB-RIF Ultra^4^RIF Resistance^|^|||||F||John Doe|20250514121638|20250514132103|Cepheid-44413S0^806149^653624^831583371^56401^20250525\rC|1|I|Notes^^Id# 118176 Sardani P Lantion CDU/Dr. A Marcil cp|I\rR|40|^MTB-RIF^^RIF^^^rpoB1^|INVALID^|||\rR|41|^MTB-RIF^^RIF^^^rpoB1^Ct|^0.0|||\rR|42|^MTB-RIF^^RIF^^^rpoB1^EndPt|^-2.0|||\rR|43|^MTB-RIF^^RIF^^^rpoB2^|INVALID^|||\rR|44|^MTB-RIF^^RIF^^^rpoB2^Ct|^0.0|||\rR|45|^MTB-RIF^^RIF^^^rpoB2^EndPt|^4.0|||\rR|46|^MTB-RIF^^RIF^^^rpoB3^|INVALID^|||\rR|47|^MTB-RIF^^RIF^^^rpoB3^Ct|^0.0|||\rR|48|^MTB-RIF^^RIF^^^rpoB3^EndPt|^8.0|||\rR|49|^MTB-RIF^^RIF^^^rpoB4^|INVALID^|||\rR|50|^MTB-RIF^^RIF^^^rpoB4^Ct|^0.0|||\rR|51|^MTB-RIF^^RIF^^^rpoB4^EndPt|^8.0|||\rR|52|^MTB-RIF^^RIF^^^rpoB1 melt^|^|||\rR|53|^MTB-RIF^^RIF^^^rpoB1 melt^Ct|^0.0|||\rR|54|^MTB-RIF^^RIF^^^rpoB1 melt^EndPt|^0.0|||\rR|55|^MTB-RIF^^RIF^^^rpoB2 melt^|^|||\rR|56|^MTB-RIF^^RIF^^^rpoB2 melt^Ct|^0.0|||\rR|57|^MTB-RIF^^RIF^^^rpoB2 melt^EndPt|^0.0|||\rR|58|^MTB-RIF^^RIF^^^rpoB3 melt^|^|||\rR|59|^MTB-RIF^^RIF^^^rpoB3 melt^Ct|^0.0|||\rR|60|^MTB-RIF^^RIF^^^rpoB3 melt^EndPt|^0.0|||\rR|61|^MTB-RIF^^RIF^^^rpoB4 melt^|^|||\rR|62|^MTB-RIF^^RIF^^^rpoB4 melt^Ct|^0.0|||\rR|63|^MTB-RIF^^RIF^^^rpoB4 melt^EndPt|^0.0|||\rR|64|^MTB-RIF^^RIF^^^rpoB1 Mut melt^|^|||\rR|65|^MTB-RIF^^RIF^^^rpoB1 Mut melt^Ct|^0.0|||\rR|66|^MTB-RIF^^RIF^^^rpoB1 Mut melt^EndPt|^0.0|||\rR|67|^MTB-RIF^^RIF^^^rpoB2 Mut melt^|^|||\rR|68|^MTB-RIF^^RIF^^^rpoB2 Mut melt^Ct|^0.0|||\rR|69|^MTB-RIF^^RIF^^^rpoB2 Mut melt^EndPt|^0.0|||\rR|70|^MTB-RIF^^RIF^^^rpoB3 Mut melt^|^|||\rR|71|^MTB-RIF^^RIF^^^rpoB3 Mut melt^Ct|^0.0|||\rR|72|^MTB-RIF^^RIF^^^rpoB3 Mut melt^EndPt|^0.0|||\rR|73|^MTB-RIF^^RIF^^^rpoB4 Mut melt A^|^|||\rR|74|^MTB-RIF^^RIF^^^rpoB4 Mut melt A^Ct|^0.0|||\rR|75|^MTB-RIF^^RIF^^^rpoB4 Mut melt A^EndPt|^0.0|||\rR|76|^MTB-RIF^^RIF^^^rpoB4 Mut melt B^|^|||\rR|77|^MTB-RIF^^RIF^^^rpoB4 Mut melt B^Ct|^0.0|||\rR|78|^MTB-RIF^^RIF^^^rpoB4 Mut melt B^EndPt|^0.0|||\rR|79|^MTB-RIF^^RIF^^^SPC^|PASS^|||\rR|80|^MTB-RIF^^RIF^^^SPC^Ct|^24.7|||\rR|81|^MTB-RIF^^RIF^^^SPC^EndPt|^159.0|||\rR|82|^MTB-RIF^^RIF^^^IS1081-IS6110^|FAIL^|||\rR|83|^MTB-RIF^^RIF^^^IS1081-IS6110^Ct|^0.0|||\rR|84|^MTB-RIF^^RIF^^^IS1081-IS6110^EndPt|^3.0|||\rL|1|N\r\u000342\r", - "lis2a": "H|@^\\|URM-8lT4abZA-06||.806149 Happy Hospital^GeneXpert^4.8|||||HNH-SENAITE||P|1394-97|20250516125515\rP|1||||^^^^|||||||||||||||||||||\rO|1|PR25A137||^^^MTB-RIF|R|20250514121638|||||||||ORH||||||||||F\rR|1|^MTB-RIF^^Xpert^Xpert MTB-RIF Ultra^4^MTB^|NOT DETECTED^|||||F||John Doe|20250514121638|20250514132103|Cepheid-44413S0^806149^653624^831583371^56401^20250525\rC|1|I|Notes^^Id# 118176 Sardani P Lantion CDU/Dr. A Marcil cp|I\rR|2|^MTB-RIF^^Xpert^^^rpoB1^|INVALID^|||\rR|3|^MTB-RIF^^Xpert^^^rpoB1^Ct|^0.0|||\rR|4|^MTB-RIF^^Xpert^^^rpoB1^EndPt|^-2.0|||\rR|5|^MTB-RIF^^Xpert^^^rpoB2^|INVALID^|||\rR|6|^MTB-RIF^^Xpert^^^rpoB2^Ct|^0.0|||\rR|7|^MTB-RIF^^Xpert^^^rpoB2^EndPt|^4.0|||\rR|8|^MTB-RIF^^Xpert^^^rpoB3^|INVALID^|||\rR|9|^MTB-RIF^^Xpert^^^rpoB3^Ct|^0.0|||\rR|10|^MTB-RIF^^Xpert^^^rpoB3^EndPt|^8.0|||\rR|11|^MTB-RIF^^Xpert^^^rpoB4^|INVALID^|||\rR|12|^MTB-RIF^^Xpert^^^rpoB4^Ct|^0.0|||\rR|13|^MTB-RIF^^Xpert^^^rpoB4^EndPt|^8.0|||\rR|14|^MTB-RIF^^Xpert^^^SPC^|PASS^|||\rR|15|^MTB-RIF^^Xpert^^^SPC^Ct|^24.7|||\rR|16|^MTB-RIF^^Xpert^^^SPC^EndPt|^159.0|||\rR|17|^MTB-RIF^^Xpert^^^^|FAIL^|||\rR|18|^MTB-RIF^^Xpert^^^IS1081-IS6110^Ct|^0.0|||\rR|19|^MTB-RIF^^Xpert^^^IS1081-IS6110^EndPt|^3.0|||\rR|20|^MTB-RIF^^Trace^Xpert MTB-RIF Ultra^4^MTB Trace^|^|||||F||John Doe|20250514121638|20250514132103|Cepheid-44413S0^806149^653624^831583371^56401^20250525\rC|1|I|Notes^^Id# 118176 Sardani P Lantion CDU/Dr. A Marcil cp|I\rR|21|^MTB-RI^^Trace^^^rpoB1^|INVALID^|||\rR|22|^MTB-RIF^^Trace^^^rpoB1^Ct|^0.0|||\rR|23|^MTB-RIF^^Trace^^^rpoB1^EndPt|^-2.0|||\rR|24|^MTB-RIF^^Trace^^^rpoB2^|INVALID^|||\rR|25|^MTB-RIF^^Trace^^^rpoB2^Ct|^0.0|||\rR|26|^MTB-RIF^^Trace^^^rpoB2^EndPt|^4.0|||\rR|27|^MTB-RIF^^Trace^^^rpoB3^|INVALID^|||\rR|28|^MTB-RIF^^Trace^^^rpoB3^Ct|^0.0|||\rR|29|^MTB-RIF^^Trace^^^rpoB3^EndPt|^8.0|||\rR|30|^MTB-RIF^^Trace^^^rpoB4^|INVALID^|||\rR|31|^MTB-RIF^^Trace^^^rpoB4^Ct|^0.0|||\rR|32|^MTB-RIF^^Trace^^^rpoB4^EndPt|^8.0|||\rR|33|^MTB-RIF^^Trace^^^SPC^|PASS^|||\rR|34|^MTB-RIF^^Trace^^^SPC^Ct|^24.7|||\rR|35|^MTB-RIF^^Trace^^^SPC^EndPt|^159.0|||\rR|36|^MTB-RIF^^Trace^^^IS1081-IS6110^|FAIL^|||\rR|37|^MTB-RIF^^Trace^^^IS1081-IS6110^Ct|^0.0|||\rR|38|^MTB-RIF^^Trace^^^IS1081-IS6110^EndPt|^3.0|||\rR|39|^MTB-RIF^^RIF^Xpert MTB-RIF Ultra^4^RIF Resistance^|^|||||F||John Doe|20250514121638|20250514132103|Cepheid-44413S0^806149^653624^831583371^56401^20250525\rC|1|I|Notes^^Id# 118176 Sardani P Lantion CDU/Dr. A Marcil cp|I\rR|40|^MTB-RIF^^RIF^^^rpoB1^|INVALID^|||\rR|41|^MTB-RIF^^RIF^^^rpoB1^Ct|^0.0|||\rR|42|^MTB-RIF^^RIF^^^rpoB1^EndPt|^-2.0|||\rR|43|^MTB-RIF^^RIF^^^rpoB2^|INVALID^|||\rR|44|^MTB-RIF^^RIF^^^rpoB2^Ct|^0.0|||\rR|45|^MTB-RIF^^RIF^^^rpoB2^EndPt|^4.0|||\rR|46|^MTB-RIF^^RIF^^^rpoB3^|INVALID^|||\rR|47|^MTB-RIF^^RIF^^^rpoB3^Ct|^0.0|||\rR|48|^MTB-RIF^^RIF^^^rpoB3^EndPt|^8.0|||\rR|49|^MTB-RIF^^RIF^^^rpoB4^|INVALID^|||\rR|50|^MTB-RIF^^RIF^^^rpoB4^Ct|^0.0|||\rR|51|^MTB-RIF^^RIF^^^rpoB4^EndPt|^8.0|||\rR|52|^MTB-RIF^^RIF^^^rpoB1 melt^|^|||\rR|53|^MTB-RIF^^RIF^^^rpoB1 melt^Ct|^0.0|||\rR|54|^MTB-RIF^^RIF^^^rpoB1 melt^EndPt|^0.0|||\rR|55|^MTB-RIF^^RIF^^^rpoB2 melt^|^|||\rR|56|^MTB-RIF^^RIF^^^rpoB2 melt^Ct|^0.0|||\rR|57|^MTB-RIF^^RIF^^^rpoB2 melt^EndPt|^0.0|||\rR|58|^MTB-RIF^^RIF^^^rpoB3 melt^|^|||\rR|59|^MTB-RIF^^RIF^^^rpoB3 melt^Ct|^0.0|||\rR|60|^MTB-RIF^^RIF^^^rpoB3 melt^EndPt|^0.0|||\rR|61|^MTB-RIF^^RIF^^^rpoB4 melt^|^|||\rR|62|^MTB-RIF^^RIF^^^rpoB4 melt^Ct|^0.0|||\rR|63|^MTB-RIF^^RIF^^^rpoB4 melt^EndPt|^0.0|||\rR|64|^MTB-RIF^^RIF^^^rpoB1 Mut melt^|^|||\rR|65|^MTB-RIF^^RIF^^^rpoB1 Mut melt^Ct|^0.0|||\rR|66|^MTB-RIF^^RIF^^^rpoB1 Mut melt^EndPt|^0.0|||\rR|67|^MTB-RIF^^RIF^^^rpoB2 Mut melt^|^|||\rR|68|^MTB-RIF^^RIF^^^rpoB2 Mut melt^Ct|^0.0|||\rR|69|^MTB-RIF^^RIF^^^rpoB2 Mut melt^EndPt|^0.0|||\rR|70|^MTB-RIF^^RIF^^^rpoB3 Mut melt^|^|||\rR|71|^MTB-RIF^^RIF^^^rpoB3 Mut melt^Ct|^0.0|||\rR|72|^MTB-RIF^^RIF^^^rpoB3 Mut melt^EndPt|^0.0|||\rR|73|^MTB-RIF^^RIF^^^rpoB4 Mut melt A^|^|||\rR|74|^MTB-RIF^^RIF^^^rpoB4 Mut melt A^Ct|^0.0|||\rR|75|^MTB-RIF^^RIF^^^rpoB4 Mut melt A^EndPt|^0.0|||\rR|76|^MTB-RIF^^RIF^^^rpoB4 Mut melt B^|^|||\rR|77|^MTB-RIF^^RIF^^^rpoB4 Mut melt B^Ct|^0.0|||\rR|78|^MTB-RIF^^RIF^^^rpoB4 Mut melt B^EndPt|^0.0|||\rR|79|^MTB-RIF^^RIF^^^SPC^|PASS^|||\rR|80|^MTB-RIF^^RIF^^^SPC^Ct|^24.7|||\rR|81|^MTB-RIF^^RIF^^^SPC^EndPt|^159.0|||\rR|82|^MTB-RIF^^RIF^^^IS1081-IS6110^|FAIL^|||\rR|83|^MTB-RIF^^RIF^^^IS1081-IS6110^Ct|^0.0|||\rR|84|^MTB-RIF^^RIF^^^IS1081-IS6110^EndPt|^3.0|||\rL|1|N\r\u0003" + "envelope_version": "1.1", + "header_rx": ".*(GeneXpert)\\^", + "hl7": "", + "lis2a": "H|@^\\|URM-8lT4abZA-06||.806149 Happy Hospital^GeneXpert^4.8|||||HNH-SENAITE||P|1394-97|20250516125515\rP|1||||^^^^|||||||||||||||||||||\rO|1|PR25A137||^^^MTB-RIF|R|20250514121638|||||||||ORH||||||||||F\rR|1|^MTB-RIF^^Xpert^Xpert MTB-RIF Ultra^4^MTB^|NOT DETECTED^|||||F||John Doe|20250514121638|20250514132103|Cepheid-44413S0^806149^653624^831583371^56401^20250525\rC|1|I|Notes^^Id# 118176 Sardani P Lantion CDU/Dr. A Marcil cp|I\rR|2|^MTB-RIF^^Xpert^^^rpoB1^|INVALID^|||\rR|3|^MTB-RIF^^Xpert^^^rpoB1^Ct|^0.0|||\rR|4|^MTB-RIF^^Xpert^^^rpoB1^EndPt|^-2.0|||\rR|5|^MTB-RIF^^Xpert^^^rpoB2^|INVALID^|||\rR|6|^MTB-RIF^^Xpert^^^rpoB2^Ct|^0.0|||\rR|7|^MTB-RIF^^Xpert^^^rpoB2^EndPt|^4.0|||\rR|8|^MTB-RIF^^Xpert^^^rpoB3^|INVALID^|||\rR|9|^MTB-RIF^^Xpert^^^rpoB3^Ct|^0.0|||\rR|10|^MTB-RIF^^Xpert^^^rpoB3^EndPt|^8.0|||\rR|11|^MTB-RIF^^Xpert^^^rpoB4^|INVALID^|||\rR|12|^MTB-RIF^^Xpert^^^rpoB4^Ct|^0.0|||\rR|13|^MTB-RIF^^Xpert^^^rpoB4^EndPt|^8.0|||\rR|14|^MTB-RIF^^Xpert^^^SPC^|PASS^|||\rR|15|^MTB-RIF^^Xpert^^^SPC^Ct|^24.7|||\rR|16|^MTB-RIF^^Xpert^^^SPC^EndPt|^159.0|||\rR|17|^MTB-RIF^^Xpert^^^^|FAIL^|||\rR|18|^MTB-RIF^^Xpert^^^IS1081-IS6110^Ct|^0.0|||\rR|19|^MTB-RIF^^Xpert^^^IS1081-IS6110^EndPt|^3.0|||\rR|20|^MTB-RIF^^Trace^Xpert MTB-RIF Ultra^4^MTB Trace^|^|||||F||John Doe|20250514121638|20250514132103|Cepheid-44413S0^806149^653624^831583371^56401^20250525\rC|1|I|Notes^^Id# 118176 Sardani P Lantion CDU/Dr. A Marcil cp|I\rR|21|^MTB-RI^^Trace^^^rpoB1^|INVALID^|||\rR|22|^MTB-RIF^^Trace^^^rpoB1^Ct|^0.0|||\rR|23|^MTB-RIF^^Trace^^^rpoB1^EndPt|^-2.0|||\rR|24|^MTB-RIF^^Trace^^^rpoB2^|INVALID^|||\rR|25|^MTB-RIF^^Trace^^^rpoB2^Ct|^0.0|||\rR|26|^MTB-RIF^^Trace^^^rpoB2^EndPt|^4.0|||\rR|27|^MTB-RIF^^Trace^^^rpoB3^|INVALID^|||\rR|28|^MTB-RIF^^Trace^^^rpoB3^Ct|^0.0|||\rR|29|^MTB-RIF^^Trace^^^rpoB3^EndPt|^8.0|||\rR|30|^MTB-RIF^^Trace^^^rpoB4^|INVALID^|||\rR|31|^MTB-RIF^^Trace^^^rpoB4^Ct|^0.0|||\rR|32|^MTB-RIF^^Trace^^^rpoB4^EndPt|^8.0|||\rR|33|^MTB-RIF^^Trace^^^SPC^|PASS^|||\rR|34|^MTB-RIF^^Trace^^^SPC^Ct|^24.7|||\rR|35|^MTB-RIF^^Trace^^^SPC^EndPt|^159.0|||\rR|36|^MTB-RIF^^Trace^^^IS1081-IS6110^|FAIL^|||\rR|37|^MTB-RIF^^Trace^^^IS1081-IS6110^Ct|^0.0|||\rR|38|^MTB-RIF^^Trace^^^IS1081-IS6110^EndPt|^3.0|||\rR|39|^MTB-RIF^^RIF^Xpert MTB-RIF Ultra^4^RIF Resistance^|^|||||F||John Doe|20250514121638|20250514132103|Cepheid-44413S0^806149^653624^831583371^56401^20250525\rC|1|I|Notes^^Id# 118176 Sardani P Lantion CDU/Dr. A Marcil cp|I\rR|40|^MTB-RIF^^RIF^^^rpoB1^|INVALID^|||\rR|41|^MTB-RIF^^RIF^^^rpoB1^Ct|^0.0|||\rR|42|^MTB-RIF^^RIF^^^rpoB1^EndPt|^-2.0|||\rR|43|^MTB-RIF^^RIF^^^rpoB2^|INVALID^|||\rR|44|^MTB-RIF^^RIF^^^rpoB2^Ct|^0.0|||\rR|45|^MTB-RIF^^RIF^^^rpoB2^EndPt|^4.0|||\rR|46|^MTB-RIF^^RIF^^^rpoB3^|INVALID^|||\rR|47|^MTB-RIF^^RIF^^^rpoB3^Ct|^0.0|||\rR|48|^MTB-RIF^^RIF^^^rpoB3^EndPt|^8.0|||\rR|49|^MTB-RIF^^RIF^^^rpoB4^|INVALID^|||\rR|50|^MTB-RIF^^RIF^^^rpoB4^Ct|^0.0|||\rR|51|^MTB-RIF^^RIF^^^rpoB4^EndPt|^8.0|||\rR|52|^MTB-RIF^^RIF^^^rpoB1 melt^|^|||\rR|53|^MTB-RIF^^RIF^^^rpoB1 melt^Ct|^0.0|||\rR|54|^MTB-RIF^^RIF^^^rpoB1 melt^EndPt|^0.0|||\rR|55|^MTB-RIF^^RIF^^^rpoB2 melt^|^|||\rR|56|^MTB-RIF^^RIF^^^rpoB2 melt^Ct|^0.0|||\rR|57|^MTB-RIF^^RIF^^^rpoB2 melt^EndPt|^0.0|||\rR|58|^MTB-RIF^^RIF^^^rpoB3 melt^|^|||\rR|59|^MTB-RIF^^RIF^^^rpoB3 melt^Ct|^0.0|||\rR|60|^MTB-RIF^^RIF^^^rpoB3 melt^EndPt|^0.0|||\rR|61|^MTB-RIF^^RIF^^^rpoB4 melt^|^|||\rR|62|^MTB-RIF^^RIF^^^rpoB4 melt^Ct|^0.0|||\rR|63|^MTB-RIF^^RIF^^^rpoB4 melt^EndPt|^0.0|||\rR|64|^MTB-RIF^^RIF^^^rpoB1 Mut melt^|^|||\rR|65|^MTB-RIF^^RIF^^^rpoB1 Mut melt^Ct|^0.0|||\rR|66|^MTB-RIF^^RIF^^^rpoB1 Mut melt^EndPt|^0.0|||\rR|67|^MTB-RIF^^RIF^^^rpoB2 Mut melt^|^|||\rR|68|^MTB-RIF^^RIF^^^rpoB2 Mut melt^Ct|^0.0|||\rR|69|^MTB-RIF^^RIF^^^rpoB2 Mut melt^EndPt|^0.0|||\rR|70|^MTB-RIF^^RIF^^^rpoB3 Mut melt^|^|||\rR|71|^MTB-RIF^^RIF^^^rpoB3 Mut melt^Ct|^0.0|||\rR|72|^MTB-RIF^^RIF^^^rpoB3 Mut melt^EndPt|^0.0|||\rR|73|^MTB-RIF^^RIF^^^rpoB4 Mut melt A^|^|||\rR|74|^MTB-RIF^^RIF^^^rpoB4 Mut melt A^Ct|^0.0|||\rR|75|^MTB-RIF^^RIF^^^rpoB4 Mut melt A^EndPt|^0.0|||\rR|76|^MTB-RIF^^RIF^^^rpoB4 Mut melt B^|^|||\rR|77|^MTB-RIF^^RIF^^^rpoB4 Mut melt B^Ct|^0.0|||\rR|78|^MTB-RIF^^RIF^^^rpoB4 Mut melt B^EndPt|^0.0|||\rR|79|^MTB-RIF^^RIF^^^SPC^|PASS^|||\rR|80|^MTB-RIF^^RIF^^^SPC^Ct|^24.7|||\rR|81|^MTB-RIF^^RIF^^^SPC^EndPt|^159.0|||\rR|82|^MTB-RIF^^RIF^^^IS1081-IS6110^|FAIL^|||\rR|83|^MTB-RIF^^RIF^^^IS1081-IS6110^Ct|^0.0|||\rR|84|^MTB-RIF^^RIF^^^IS1081-IS6110^EndPt|^3.0|||\rL|1|N\r\u0003", + "version": "1.0.0" } } \ No newline at end of file diff --git a/src/senaite/astm/tests/data/envelopes/mini_vidas_mock.json b/src/senaite/astm/tests/data/envelopes/mini_vidas_mock.json index 582a938..3270ddb 100644 --- a/src/senaite/astm/tests/data/envelopes/mini_vidas_mock.json +++ b/src/senaite/astm/tests/data/envelopes/mini_vidas_mock.json @@ -1,4 +1,5 @@ { + "C": [], "H": [ { "address": null, @@ -41,6 +42,7 @@ "type": "L" } ], + "M": [], "O": [ { "action_code": null, @@ -115,6 +117,7 @@ "weight": null } ], + "Q": [], "R": [ { "abnormal_flag": null, @@ -135,6 +138,10 @@ ], "metadata": { "astm": "\u00021H|\\\\^&|||miniVidas^biomerieux^1.0.0|||||||||20241025183500\r\u000358\n\u00022P|1||||test_patient|||||||||||||||||||||||||||||\r\u00034F\n\u00023O|1|Z1G021SCR||Anti-HBc Total II||||||||||||||||||20241025183500||||||||\r\u0003D5\n\u00024R|1|HBCT|0.05|||||Positif||||20241025183500|\r\u000396\n\u00025L|1|N\r\u000308", - "lis2a": "H|\\\\^&|||miniVidas^biomerieux^1.0.0|||||||||20241025183500\r\u0003P|1||||test_patient|||||||||||||||||||||||||||||\r\u0003O|1|Z1G021SCR||Anti-HBc Total II||||||||||||||||||20241025183500||||||||\r\u0003R|1|HBCT|0.05|||||Positif||||20241025183500|\r\u0003L|1|N\r\u0003" + "envelope_version": "1.1", + "header_rx": ".*miniVidas\\^", + "hl7": "", + "lis2a": "H|\\\\^&|||miniVidas^biomerieux^1.0.0|||||||||20241025183500\r\u0003P|1||||test_patient|||||||||||||||||||||||||||||\r\u0003O|1|Z1G021SCR||Anti-HBc Total II||||||||||||||||||20241025183500||||||||\r\u0003R|1|HBCT|0.05|||||Positif||||20241025183500|\r\u0003L|1|N\r\u0003", + "version": "1.0.0" } } \ No newline at end of file diff --git a/src/senaite/astm/tests/data/envelopes/pentra_xlr.json b/src/senaite/astm/tests/data/envelopes/pentra_xlr.json index ed5831c..700f8ee 100644 --- a/src/senaite/astm/tests/data/envelopes/pentra_xlr.json +++ b/src/senaite/astm/tests/data/envelopes/pentra_xlr.json @@ -58,6 +58,7 @@ "type": "L" } ], + "M": [], "O": [ { "action_code": null, @@ -144,6 +145,7 @@ "weight": null } ], + "Q": [], "R": [ { "abnormal_flag": null, @@ -631,6 +633,10 @@ ], "metadata": { "astm": "\u00021H|\\^&|||ABX|||||||P|E1394-97|20220727121551\r\u000358\n\u00022P|1||||Mohale^Rita||19771201|F\r\u0003C9\n\u00023O|1|S1234^00^00||^^^DIF|||202205270000|202205260000|||||||Standard||||||||||F\r\u000383\n\u00024R|1|^^^WBC^804-5^1|8.5|1||||W||NNE NNEMT||20220727121550\r\u0003E2\n\u00025C|1|I|Alarm_WBC^LMNE-^BASO+^LL^NL^LN^NO^SL1|I\r\u0003D7\n\u00026C|2|I|LARGE IMMATURE CELL^NRBCs|I\r\u000362\n\u00027R|2|^^^LYM#^731-0^1|3.29|1||||W||NNE NNEMT||20220727121550\r\u00034A\n\u00020R|3|^^^LYM%^736-9^1|38.6|1||||W||NNE NNEMT||20220727121550\r\u000357\n\u00021R|4|^^^MON#^742-7^1|0.15|1||L||W||NNE NNEMT||20220727121550\r\u00038B\n\u00022R|5|^^^MON%^744-3^1|1.8|1||||W||NNE NNEMT||20220727121550\r\u000314\n\u00023R|6|^^^NEU#^751-8^1|4.62|1||||W||NNE NNEMT||20220727121550\r\u000348\n\u00024R|7|^^^NEU%^770-8^1|54.2|1||||W||NNE NNEMT||20220727121550\r\u00034C\n\u00025R|8|^^^EOS#^711-2^1|0.46|1||||W||NNE NNEMT||20220727121550\r\u00033F\n\u00026R|9|^^^EOS%^713-8^1|5.4|1||||W||NNE NNEMT||20220727121550\r\u00031A\n\u00027R|10|^^^BAS#^704-7^1|-----|1||HH||X||NNE NNEMT||20220727121550\r\u00030A\n\u00020R|11|^^^BAS%^706-2^1|-----|1||||X||NNE NNEMT||20220727121550\r\u000373\n\u00021R|12|^^^RBC^789-9^1|4.65|1||||F||NNE NNEMT||20220727121550\r\u00033D\n\u00022R|13|^^^HGB^717-9^1|14.0|1||||F||NNE NNEMT||20220727121550\r\u000326\n\u00023R|14|^^^HCT^4544-3^1|40.9|1||||F||NNE NNEMT||20220727121550\r\u00036A\n\u00024R|15|^^^MCV^787-2^1|88|1||||F||NNE NNEMT||20220727121550\r\u0003EC\n\u00025R|16|^^^MCH^785-6^1|30.1|1||||F||NNE NNEMT||20220727121550\r\u000334\n\u00026R|17|^^^MCHC^786-4^1|34.2|1||||F||NNE NNEMT||20220727121550\r\u00037D\n\u00027R|18|^^^RDW^788-0^1|13.5|1||||F||NNE NNEMT||20220727121550\r\u00034F\n\u00020R|19|^^^PLT^777-3^1|234|1||||F||NNE NNEMT||20220727121550\r\u00031F\n\u00021C|1|I|PLATELET AGGREGATS|I\r\u000347\n\u00022R|20|^^^MPV^776-5^1|10.2|1||||F||NNE NNEMT||20220727121550\r\u000345\n\u00023R|21|^^^RDWSD^2100-5^1|43|1||||F||NNE NNEMT||20220727121550\r\u00039D\n\u00024L|1|N\r\u000307", - "lis2a": "H|\\^&|||ABX|||||||P|E1394-97|20220727121551\r\u0003P|1||||Mohale^Rita||19771201|F\r\u0003O|1|S1234^00^00||^^^DIF|||202205270000|202205260000|||||||Standard||||||||||F\r\u0003R|1|^^^WBC^804-5^1|8.5|1||||W||NNE NNEMT||20220727121550\r\u0003C|1|I|Alarm_WBC^LMNE-^BASO+^LL^NL^LN^NO^SL1|I\r\u0003C|2|I|LARGE IMMATURE CELL^NRBCs|I\r\u0003R|2|^^^LYM#^731-0^1|3.29|1||||W||NNE NNEMT||20220727121550\r\u0003R|3|^^^LYM%^736-9^1|38.6|1||||W||NNE NNEMT||20220727121550\r\u0003R|4|^^^MON#^742-7^1|0.15|1||L||W||NNE NNEMT||20220727121550\r\u0003R|5|^^^MON%^744-3^1|1.8|1||||W||NNE NNEMT||20220727121550\r\u0003R|6|^^^NEU#^751-8^1|4.62|1||||W||NNE NNEMT||20220727121550\r\u0003R|7|^^^NEU%^770-8^1|54.2|1||||W||NNE NNEMT||20220727121550\r\u0003R|8|^^^EOS#^711-2^1|0.46|1||||W||NNE NNEMT||20220727121550\r\u0003R|9|^^^EOS%^713-8^1|5.4|1||||W||NNE NNEMT||20220727121550\r\u0003R|10|^^^BAS#^704-7^1|-----|1||HH||X||NNE NNEMT||20220727121550\r\u0003R|11|^^^BAS%^706-2^1|-----|1||||X||NNE NNEMT||20220727121550\r\u0003R|12|^^^RBC^789-9^1|4.65|1||||F||NNE NNEMT||20220727121550\r\u0003R|13|^^^HGB^717-9^1|14.0|1||||F||NNE NNEMT||20220727121550\r\u0003R|14|^^^HCT^4544-3^1|40.9|1||||F||NNE NNEMT||20220727121550\r\u0003R|15|^^^MCV^787-2^1|88|1||||F||NNE NNEMT||20220727121550\r\u0003R|16|^^^MCH^785-6^1|30.1|1||||F||NNE NNEMT||20220727121550\r\u0003R|17|^^^MCHC^786-4^1|34.2|1||||F||NNE NNEMT||20220727121550\r\u0003R|18|^^^RDW^788-0^1|13.5|1||||F||NNE NNEMT||20220727121550\r\u0003R|19|^^^PLT^777-3^1|234|1||||F||NNE NNEMT||20220727121550\r\u0003C|1|I|PLATELET AGGREGATS|I\r\u0003R|20|^^^MPV^776-5^1|10.2|1||||F||NNE NNEMT||20220727121550\r\u0003R|21|^^^RDWSD^2100-5^1|43|1||||F||NNE NNEMT||20220727121550\r\u0003L|1|N\r\u0003" + "envelope_version": "1.1", + "header_rx": ".*(?<=\\|)(LIS|ABX)(?=\\|).*", + "hl7": "", + "lis2a": "H|\\^&|||ABX|||||||P|E1394-97|20220727121551\r\u0003P|1||||Mohale^Rita||19771201|F\r\u0003O|1|S1234^00^00||^^^DIF|||202205270000|202205260000|||||||Standard||||||||||F\r\u0003R|1|^^^WBC^804-5^1|8.5|1||||W||NNE NNEMT||20220727121550\r\u0003C|1|I|Alarm_WBC^LMNE-^BASO+^LL^NL^LN^NO^SL1|I\r\u0003C|2|I|LARGE IMMATURE CELL^NRBCs|I\r\u0003R|2|^^^LYM#^731-0^1|3.29|1||||W||NNE NNEMT||20220727121550\r\u0003R|3|^^^LYM%^736-9^1|38.6|1||||W||NNE NNEMT||20220727121550\r\u0003R|4|^^^MON#^742-7^1|0.15|1||L||W||NNE NNEMT||20220727121550\r\u0003R|5|^^^MON%^744-3^1|1.8|1||||W||NNE NNEMT||20220727121550\r\u0003R|6|^^^NEU#^751-8^1|4.62|1||||W||NNE NNEMT||20220727121550\r\u0003R|7|^^^NEU%^770-8^1|54.2|1||||W||NNE NNEMT||20220727121550\r\u0003R|8|^^^EOS#^711-2^1|0.46|1||||W||NNE NNEMT||20220727121550\r\u0003R|9|^^^EOS%^713-8^1|5.4|1||||W||NNE NNEMT||20220727121550\r\u0003R|10|^^^BAS#^704-7^1|-----|1||HH||X||NNE NNEMT||20220727121550\r\u0003R|11|^^^BAS%^706-2^1|-----|1||||X||NNE NNEMT||20220727121550\r\u0003R|12|^^^RBC^789-9^1|4.65|1||||F||NNE NNEMT||20220727121550\r\u0003R|13|^^^HGB^717-9^1|14.0|1||||F||NNE NNEMT||20220727121550\r\u0003R|14|^^^HCT^4544-3^1|40.9|1||||F||NNE NNEMT||20220727121550\r\u0003R|15|^^^MCV^787-2^1|88|1||||F||NNE NNEMT||20220727121550\r\u0003R|16|^^^MCH^785-6^1|30.1|1||||F||NNE NNEMT||20220727121550\r\u0003R|17|^^^MCHC^786-4^1|34.2|1||||F||NNE NNEMT||20220727121550\r\u0003R|18|^^^RDW^788-0^1|13.5|1||||F||NNE NNEMT||20220727121550\r\u0003R|19|^^^PLT^777-3^1|234|1||||F||NNE NNEMT||20220727121550\r\u0003C|1|I|PLATELET AGGREGATS|I\r\u0003R|20|^^^MPV^776-5^1|10.2|1||||F||NNE NNEMT||20220727121550\r\u0003R|21|^^^RDWSD^2100-5^1|43|1||||F||NNE NNEMT||20220727121550\r\u0003L|1|N\r\u0003", + "version": "1.0.0" } } \ No newline at end of file diff --git a/src/senaite/astm/tests/data/envelopes/spotchem_el_mock.json b/src/senaite/astm/tests/data/envelopes/spotchem_el_mock.json index 0231433..3438af2 100644 --- a/src/senaite/astm/tests/data/envelopes/spotchem_el_mock.json +++ b/src/senaite/astm/tests/data/envelopes/spotchem_el_mock.json @@ -1,4 +1,5 @@ { + "C": [], "H": [ { "address": null, @@ -41,6 +42,7 @@ "type": "L" } ], + "M": [], "O": [ { "action_code": null, @@ -76,6 +78,8 @@ "volume": null } ], + "P": [], + "Q": [], "R": [ { "abnormal_flag": null, @@ -128,6 +132,10 @@ ], "metadata": { "astm": "\u00021H|\\\\^&|||SE-1520^Spotchem^1.0.0|||||||||20241029130800\r\u00034A\n\u00022O|1|1DC042FP||B. Plasma|||20241029130800||||||||||||||||||\r\u000370\n\u00023R|1|Na|131.0|mmol/L||||||||20241029130800|\r\u0003A4\n\u00024R|2|K|9.7|mmol/L||||||||20241029130800|\r\u0003ED\n\u00025R|3|Cl|96.0|mmol/L||||||||20241029130800|\r\u000382\n\u00026L|1|N\r\u000309", - "lis2a": "H|\\\\^&|||SE-1520^Spotchem^1.0.0|||||||||20241029130800\r\u0003O|1|1DC042FP||B. Plasma|||20241029130800||||||||||||||||||\r\u0003R|1|Na|131.0|mmol/L||||||||20241029130800|\r\u0003R|2|K|9.7|mmol/L||||||||20241029130800|\r\u0003R|3|Cl|96.0|mmol/L||||||||20241029130800|\r\u0003L|1|N\r\u0003" + "envelope_version": "1.1", + "header_rx": ".*SE-1520\\^", + "hl7": "", + "lis2a": "H|\\\\^&|||SE-1520^Spotchem^1.0.0|||||||||20241029130800\r\u0003O|1|1DC042FP||B. Plasma|||20241029130800||||||||||||||||||\r\u0003R|1|Na|131.0|mmol/L||||||||20241029130800|\r\u0003R|2|K|9.7|mmol/L||||||||20241029130800|\r\u0003R|3|Cl|96.0|mmol/L||||||||20241029130800|\r\u0003L|1|N\r\u0003", + "version": "1.0.0" } } \ No newline at end of file diff --git a/src/senaite/astm/tests/data/envelopes/sysmex_xn550.json b/src/senaite/astm/tests/data/envelopes/sysmex_xn550.json index 0e5b046..79a7e6f 100644 --- a/src/senaite/astm/tests/data/envelopes/sysmex_xn550.json +++ b/src/senaite/astm/tests/data/envelopes/sysmex_xn550.json @@ -64,6 +64,7 @@ "type": "L" } ], + "M": [], "O": [ { "action_code": "N", @@ -317,6 +318,7 @@ "weight": null } ], + "Q": [], "R": [ { "abnormal_flag": "N", @@ -1346,6 +1348,10 @@ ], "metadata": { "astm": "\u00021H|\\^&||| XN-550^00-24^22723^^^^BD634545||||||||E1394-97\rP|1|||37182|^Jim^Brown||19870626|M|||||^DR.1||||||||||||^^^WEST\rC|1||POST HD\rO|1||^^ 27^M|^^^^WBC\\^^^^RBC\\^^^^HGB\\^^^^HCT\\^^^^MCV\\^^^^MCH\\^^^^MCHC\\^^^^PLT\\^^^^RDW-SD\\^^^^RDW-CV\\^^^^MPV\\^^^^NEUT#\\^^^^LYMPH#\\^^^^MONO#\\^^^^EO#\\^^^^BASO#\\^^^^NEUT%\\^^^^LYMPH%\\^^^^MONO%\\^^^^EO%\\^^^^BASO%\\^^^^IG#\\^^^^IG%|||||||N||||||||||||||F\rC|1||\rR|1|^^^^WBC^1|8.13|10*3/uL||N||F||||20240627135407\rR|2|^^^^RBC^1|2.60|10*6/uL||N||F||||20240627135407\rR|3|^^^^HGB^1|8.0|g/dL||N||F||||20240627135407\rR|4|^^^^HCT^1|22.7|%||L||F||||20240627135407\rR|5|^^^^MCV^1|87.3|fL||N||F||||20240627135407\rR|6|^^^^MCH^1|30.8|pg||N||F||||20240627135407\rR|7|^^^^MCHC^1|35.2|g/dL||N||F||||20240627135407\rR|8|^^^^PLT^1|99|10*3/uL||N||F||||20240627135407\rR|9|^^^^NEUT%^1|57.4|%||N||F||||20240627135407\rR|10|^^^^LYMPH%^1|12.8|%||L||F||||20240627135407\rR|11|^^^^MONO%^1|7.3|%||N||F||||20240627135407\rR|12|^^^^EO%^1|22.1|%||H||F||||20240627135407\rR|13|^^^^BASO%^1|0.4|%||N||F||||20240627135407\rR|14|^^^^NEUT#^1|4.67|10*3/uL||N||F||||20240627135407\rR|15|^^^^LYMPH#^1|1.04|10*3/uL||N||F||||20240627135407\rR|16|^^^^MONO#^1|0.59|10*3/uL||N||F||||20240627135407\rR|17|^^^^EO#^1|1.80|10*3/uL||H||F||||20240627135407\rR|18|^^^^BASO#^1|0.03|10*3/uL||N||F||||20240627135407\rR|19|^^^^IG%^1|0.2|%||N||F||||20240627135407\rR|20|^^^^IG#^1|0.02|10*3/uL||N||F||||20240627135407\rR|21|^^^^RDW-SD^1|47.5|fL||N||F||||20240627135407\rR|22|^^^^RDW-CV^1|14.8|%||N||F||||20240627135407\rR|23|^^^^MPV^1|8.1|fL||L||F||||20240627135407\rR|24|^^^^Eosinophilia||||A||F||||20240627135407\rR|25|^^^^Anemia||||A||F||||20240627135407\rR|26|^^^^Blasts/Abn_Lympho?|40|||||F||||20240627135407\rR|27|^^^^Left_Shift?|0|||||F||||20240627135407\rR|28|^^^^Atypical_Lympho?|10|||||F||||20240627135407\rR|29|^^^^NRBC?|0|||||F||||20240627135407\rR|30|^^^^RBC_Agglutination?|70|||||F||||20240627135407\rR|31|^^^^Turbidity/HGB_Interference?|90|||||F||||20240627135407\rR|32|^^^^Iron_Deficiency?|80|||||F||||20240627135407\rR|33|^^^^HGB_Defect?|80|||||F||||20240627135407\rR|34|^^^^Fragments?|0|||||F||||20240627135407\rR|35|^^^^PLT_Clumps?|0|||||F||||20240627135407\rR|36|^^^^Positive_Diff||||A||F||||20240627135407\rR|37|^^^^Positive_Count||||A||F||||20240627135407\rR|38|^^^^SCAT_WDF|PNG&R&20240628&R&2024_06_27_13_54_27_WDF.PNG|||N||F||||20240627135407\rR|39|^^^^SCAT_WDF-CBC|PNG&R&20240628&R&2024_06_27_13_54_27_WDF_CBC.PNG|||N||F||||20240627135407\rR|40|^^^^DIST_RBC|PNG&R&20240628&R&2024_06_27_13_54_27_RBC.PNG|||N||F||||20240627135407\rR|41|^^^^DIST_PLT|PNG&R&20240628&R&2024_06_27_13_54_27_PLT.PNG|||N||F||||20240627135407\rC|1||\rL|1|N\r\u000345\r", - "lis2a": "H|\\^&||| XN-550^00-24^22723^^^^BD634545||||||||E1394-97\rP|1|||37182|^Jim^Brown||19870626|M|||||^DR.1||||||||||||^^^WEST\rC|1||POST HD\rO|1||^^ 27^M|^^^^WBC\\^^^^RBC\\^^^^HGB\\^^^^HCT\\^^^^MCV\\^^^^MCH\\^^^^MCHC\\^^^^PLT\\^^^^RDW-SD\\^^^^RDW-CV\\^^^^MPV\\^^^^NEUT#\\^^^^LYMPH#\\^^^^MONO#\\^^^^EO#\\^^^^BASO#\\^^^^NEUT%\\^^^^LYMPH%\\^^^^MONO%\\^^^^EO%\\^^^^BASO%\\^^^^IG#\\^^^^IG%|||||||N||||||||||||||F\rC|1||\rR|1|^^^^WBC^1|8.13|10*3/uL||N||F||||20240627135407\rR|2|^^^^RBC^1|2.60|10*6/uL||N||F||||20240627135407\rR|3|^^^^HGB^1|8.0|g/dL||N||F||||20240627135407\rR|4|^^^^HCT^1|22.7|%||L||F||||20240627135407\rR|5|^^^^MCV^1|87.3|fL||N||F||||20240627135407\rR|6|^^^^MCH^1|30.8|pg||N||F||||20240627135407\rR|7|^^^^MCHC^1|35.2|g/dL||N||F||||20240627135407\rR|8|^^^^PLT^1|99|10*3/uL||N||F||||20240627135407\rR|9|^^^^NEUT%^1|57.4|%||N||F||||20240627135407\rR|10|^^^^LYMPH%^1|12.8|%||L||F||||20240627135407\rR|11|^^^^MONO%^1|7.3|%||N||F||||20240627135407\rR|12|^^^^EO%^1|22.1|%||H||F||||20240627135407\rR|13|^^^^BASO%^1|0.4|%||N||F||||20240627135407\rR|14|^^^^NEUT#^1|4.67|10*3/uL||N||F||||20240627135407\rR|15|^^^^LYMPH#^1|1.04|10*3/uL||N||F||||20240627135407\rR|16|^^^^MONO#^1|0.59|10*3/uL||N||F||||20240627135407\rR|17|^^^^EO#^1|1.80|10*3/uL||H||F||||20240627135407\rR|18|^^^^BASO#^1|0.03|10*3/uL||N||F||||20240627135407\rR|19|^^^^IG%^1|0.2|%||N||F||||20240627135407\rR|20|^^^^IG#^1|0.02|10*3/uL||N||F||||20240627135407\rR|21|^^^^RDW-SD^1|47.5|fL||N||F||||20240627135407\rR|22|^^^^RDW-CV^1|14.8|%||N||F||||20240627135407\rR|23|^^^^MPV^1|8.1|fL||L||F||||20240627135407\rR|24|^^^^Eosinophilia||||A||F||||20240627135407\rR|25|^^^^Anemia||||A||F||||20240627135407\rR|26|^^^^Blasts/Abn_Lympho?|40|||||F||||20240627135407\rR|27|^^^^Left_Shift?|0|||||F||||20240627135407\rR|28|^^^^Atypical_Lympho?|10|||||F||||20240627135407\rR|29|^^^^NRBC?|0|||||F||||20240627135407\rR|30|^^^^RBC_Agglutination?|70|||||F||||20240627135407\rR|31|^^^^Turbidity/HGB_Interference?|90|||||F||||20240627135407\rR|32|^^^^Iron_Deficiency?|80|||||F||||20240627135407\rR|33|^^^^HGB_Defect?|80|||||F||||20240627135407\rR|34|^^^^Fragments?|0|||||F||||20240627135407\rR|35|^^^^PLT_Clumps?|0|||||F||||20240627135407\rR|36|^^^^Positive_Diff||||A||F||||20240627135407\rR|37|^^^^Positive_Count||||A||F||||20240627135407\rR|38|^^^^SCAT_WDF|PNG&R&20240628&R&2024_06_27_13_54_27_WDF.PNG|||N||F||||20240627135407\rR|39|^^^^SCAT_WDF-CBC|PNG&R&20240628&R&2024_06_27_13_54_27_WDF_CBC.PNG|||N||F||||20240627135407\rR|40|^^^^DIST_RBC|PNG&R&20240628&R&2024_06_27_13_54_27_RBC.PNG|||N||F||||20240627135407\rR|41|^^^^DIST_PLT|PNG&R&20240628&R&2024_06_27_13_54_27_PLT.PNG|||N||F||||20240627135407\rC|1||\rL|1|N\r\u0003" + "envelope_version": "1.1", + "header_rx": ".*XN-(550|530|450|430|350|330|150|110)\\^", + "hl7": "", + "lis2a": "H|\\^&||| XN-550^00-24^22723^^^^BD634545||||||||E1394-97\rP|1|||37182|^Jim^Brown||19870626|M|||||^DR.1||||||||||||^^^WEST\rC|1||POST HD\rO|1||^^ 27^M|^^^^WBC\\^^^^RBC\\^^^^HGB\\^^^^HCT\\^^^^MCV\\^^^^MCH\\^^^^MCHC\\^^^^PLT\\^^^^RDW-SD\\^^^^RDW-CV\\^^^^MPV\\^^^^NEUT#\\^^^^LYMPH#\\^^^^MONO#\\^^^^EO#\\^^^^BASO#\\^^^^NEUT%\\^^^^LYMPH%\\^^^^MONO%\\^^^^EO%\\^^^^BASO%\\^^^^IG#\\^^^^IG%|||||||N||||||||||||||F\rC|1||\rR|1|^^^^WBC^1|8.13|10*3/uL||N||F||||20240627135407\rR|2|^^^^RBC^1|2.60|10*6/uL||N||F||||20240627135407\rR|3|^^^^HGB^1|8.0|g/dL||N||F||||20240627135407\rR|4|^^^^HCT^1|22.7|%||L||F||||20240627135407\rR|5|^^^^MCV^1|87.3|fL||N||F||||20240627135407\rR|6|^^^^MCH^1|30.8|pg||N||F||||20240627135407\rR|7|^^^^MCHC^1|35.2|g/dL||N||F||||20240627135407\rR|8|^^^^PLT^1|99|10*3/uL||N||F||||20240627135407\rR|9|^^^^NEUT%^1|57.4|%||N||F||||20240627135407\rR|10|^^^^LYMPH%^1|12.8|%||L||F||||20240627135407\rR|11|^^^^MONO%^1|7.3|%||N||F||||20240627135407\rR|12|^^^^EO%^1|22.1|%||H||F||||20240627135407\rR|13|^^^^BASO%^1|0.4|%||N||F||||20240627135407\rR|14|^^^^NEUT#^1|4.67|10*3/uL||N||F||||20240627135407\rR|15|^^^^LYMPH#^1|1.04|10*3/uL||N||F||||20240627135407\rR|16|^^^^MONO#^1|0.59|10*3/uL||N||F||||20240627135407\rR|17|^^^^EO#^1|1.80|10*3/uL||H||F||||20240627135407\rR|18|^^^^BASO#^1|0.03|10*3/uL||N||F||||20240627135407\rR|19|^^^^IG%^1|0.2|%||N||F||||20240627135407\rR|20|^^^^IG#^1|0.02|10*3/uL||N||F||||20240627135407\rR|21|^^^^RDW-SD^1|47.5|fL||N||F||||20240627135407\rR|22|^^^^RDW-CV^1|14.8|%||N||F||||20240627135407\rR|23|^^^^MPV^1|8.1|fL||L||F||||20240627135407\rR|24|^^^^Eosinophilia||||A||F||||20240627135407\rR|25|^^^^Anemia||||A||F||||20240627135407\rR|26|^^^^Blasts/Abn_Lympho?|40|||||F||||20240627135407\rR|27|^^^^Left_Shift?|0|||||F||||20240627135407\rR|28|^^^^Atypical_Lympho?|10|||||F||||20240627135407\rR|29|^^^^NRBC?|0|||||F||||20240627135407\rR|30|^^^^RBC_Agglutination?|70|||||F||||20240627135407\rR|31|^^^^Turbidity/HGB_Interference?|90|||||F||||20240627135407\rR|32|^^^^Iron_Deficiency?|80|||||F||||20240627135407\rR|33|^^^^HGB_Defect?|80|||||F||||20240627135407\rR|34|^^^^Fragments?|0|||||F||||20240627135407\rR|35|^^^^PLT_Clumps?|0|||||F||||20240627135407\rR|36|^^^^Positive_Diff||||A||F||||20240627135407\rR|37|^^^^Positive_Count||||A||F||||20240627135407\rR|38|^^^^SCAT_WDF|PNG&R&20240628&R&2024_06_27_13_54_27_WDF.PNG|||N||F||||20240627135407\rR|39|^^^^SCAT_WDF-CBC|PNG&R&20240628&R&2024_06_27_13_54_27_WDF_CBC.PNG|||N||F||||20240627135407\rR|40|^^^^DIST_RBC|PNG&R&20240628&R&2024_06_27_13_54_27_RBC.PNG|||N||F||||20240627135407\rR|41|^^^^DIST_PLT|PNG&R&20240628&R&2024_06_27_13_54_27_PLT.PNG|||N||F||||20240627135407\rC|1||\rL|1|N\r\u0003", + "version": "1.0.0" } } \ No newline at end of file diff --git a/src/senaite/astm/tests/data/envelopes/sysmex_xp100.json b/src/senaite/astm/tests/data/envelopes/sysmex_xp100.json index 9618197..1454323 100644 --- a/src/senaite/astm/tests/data/envelopes/sysmex_xp100.json +++ b/src/senaite/astm/tests/data/envelopes/sysmex_xp100.json @@ -1,4 +1,5 @@ { + "C": [], "H": [ { "address": null, @@ -41,6 +42,7 @@ "type": "L" } ], + "M": [], "O": [ { "action_code": "N", @@ -261,6 +263,7 @@ "weight": null } ], + "Q": [], "R": [ { "abnormal_flag": "N", @@ -725,6 +728,10 @@ ], "metadata": { "astm": "\u00021H|\\^&|||XP-100^00-13^^^^A7869^BS649542||||||||E1394-97\rP|1\rO|1||^^ 113^A|^^^^WBC\\^^^^RBC\\^^^^HGB\\^^^^HCT\\^^^^MCV\\^^^^MCH\\^^^^MCHC\\^^^^PLT\\^^^^LYM%\\^^^^MXD%\\^^^^NEUT%\\^^^^LYM#\\^^^^MXD#\\^^^^NEUT#\\^^^^RDW-SD\\^^^^RDW-CV\\^^^^PDW\\^^^^MPV\\^^^^P-LCR\\^^^^PCT|||||||N||||||||||||||F\rR|1|^^^^WBC^1| 5.5|10*3/uL||N|||| ||20240723172452\rR|2|^^^^RBC^1| 2.87|10*6/uL||N|||| ||20240723172452\rR|3|^^^^HGB^1| 10.1|g/dL||N|||| ||20240723172452\rR|4|^^^^HCT^1| 24.2|%||L|||| ||20240723172452\rR|5|^^^^MCV^1| 84.3|fL||L|||| ||20240723172452\rR|6|^^^^MCH^1| 35.2|pg||N|||| ||20240723172452\rR|7|^^^^MCHC^1| 41.7|g/dL||H|||| ||20240723172452\rR|8|^^^^PLT^1| 170|10*3/uL||N|||| ||20240723172452\rR|9|^^^^LYM%^1| 26.4|%||N|||| ||20240723172452\rR|10|^^^^MXD%^1| 10.2|%||N|||| ||20240723172452\rR|11|^^^^NEUT%^1| 63.4|%||N|||| ||20240723172452\rR|12|^^^^LYM#^1| 1.5|10*3/uL||N|||| ||20240723172452\rR|13|^^^^MXD#^1| 0.6|10*3/uL||N|||| ||20240723172452\rR|14|^^^^NEUT#^1| 3.4|10*3/uL||N|||| ||20240723172452\rR|15|^^^^RDW-SD^1| 38.5|fL||N|||| ||20240723172452\rR|16|^^^^RDW-CV^1| 11.8|%||N|||| ||20240723172452\rR|17|^^^^PDW^1| 12.8|fL||N|||| ||20240723172452\rR|18|^^^^MPV^1| 10.2|fL||N|||| ||20240723172452\rR|19|^^^^P-LCR^1| 26.9|%||N|||| ||20240723172452\rR|20|^^^^PCT^1| 0.17|%||N|||| ||20240723172452\rL|1|N\r\u000357\r", - "lis2a": "H|\\^&|||XP-100^00-13^^^^A7869^BS649542||||||||E1394-97\rP|1\rO|1||^^ 113^A|^^^^WBC\\^^^^RBC\\^^^^HGB\\^^^^HCT\\^^^^MCV\\^^^^MCH\\^^^^MCHC\\^^^^PLT\\^^^^LYM%\\^^^^MXD%\\^^^^NEUT%\\^^^^LYM#\\^^^^MXD#\\^^^^NEUT#\\^^^^RDW-SD\\^^^^RDW-CV\\^^^^PDW\\^^^^MPV\\^^^^P-LCR\\^^^^PCT|||||||N||||||||||||||F\rR|1|^^^^WBC^1| 5.5|10*3/uL||N|||| ||20240723172452\rR|2|^^^^RBC^1| 2.87|10*6/uL||N|||| ||20240723172452\rR|3|^^^^HGB^1| 10.1|g/dL||N|||| ||20240723172452\rR|4|^^^^HCT^1| 24.2|%||L|||| ||20240723172452\rR|5|^^^^MCV^1| 84.3|fL||L|||| ||20240723172452\rR|6|^^^^MCH^1| 35.2|pg||N|||| ||20240723172452\rR|7|^^^^MCHC^1| 41.7|g/dL||H|||| ||20240723172452\rR|8|^^^^PLT^1| 170|10*3/uL||N|||| ||20240723172452\rR|9|^^^^LYM%^1| 26.4|%||N|||| ||20240723172452\rR|10|^^^^MXD%^1| 10.2|%||N|||| ||20240723172452\rR|11|^^^^NEUT%^1| 63.4|%||N|||| ||20240723172452\rR|12|^^^^LYM#^1| 1.5|10*3/uL||N|||| ||20240723172452\rR|13|^^^^MXD#^1| 0.6|10*3/uL||N|||| ||20240723172452\rR|14|^^^^NEUT#^1| 3.4|10*3/uL||N|||| ||20240723172452\rR|15|^^^^RDW-SD^1| 38.5|fL||N|||| ||20240723172452\rR|16|^^^^RDW-CV^1| 11.8|%||N|||| ||20240723172452\rR|17|^^^^PDW^1| 12.8|fL||N|||| ||20240723172452\rR|18|^^^^MPV^1| 10.2|fL||N|||| ||20240723172452\rR|19|^^^^P-LCR^1| 26.9|%||N|||| ||20240723172452\rR|20|^^^^PCT^1| 0.17|%||N|||| ||20240723172452\rL|1|N\r\u0003" + "envelope_version": "1.1", + "header_rx": ".*XP-(100|300)\\^", + "hl7": "", + "lis2a": "H|\\^&|||XP-100^00-13^^^^A7869^BS649542||||||||E1394-97\rP|1\rO|1||^^ 113^A|^^^^WBC\\^^^^RBC\\^^^^HGB\\^^^^HCT\\^^^^MCV\\^^^^MCH\\^^^^MCHC\\^^^^PLT\\^^^^LYM%\\^^^^MXD%\\^^^^NEUT%\\^^^^LYM#\\^^^^MXD#\\^^^^NEUT#\\^^^^RDW-SD\\^^^^RDW-CV\\^^^^PDW\\^^^^MPV\\^^^^P-LCR\\^^^^PCT|||||||N||||||||||||||F\rR|1|^^^^WBC^1| 5.5|10*3/uL||N|||| ||20240723172452\rR|2|^^^^RBC^1| 2.87|10*6/uL||N|||| ||20240723172452\rR|3|^^^^HGB^1| 10.1|g/dL||N|||| ||20240723172452\rR|4|^^^^HCT^1| 24.2|%||L|||| ||20240723172452\rR|5|^^^^MCV^1| 84.3|fL||L|||| ||20240723172452\rR|6|^^^^MCH^1| 35.2|pg||N|||| ||20240723172452\rR|7|^^^^MCHC^1| 41.7|g/dL||H|||| ||20240723172452\rR|8|^^^^PLT^1| 170|10*3/uL||N|||| ||20240723172452\rR|9|^^^^LYM%^1| 26.4|%||N|||| ||20240723172452\rR|10|^^^^MXD%^1| 10.2|%||N|||| ||20240723172452\rR|11|^^^^NEUT%^1| 63.4|%||N|||| ||20240723172452\rR|12|^^^^LYM#^1| 1.5|10*3/uL||N|||| ||20240723172452\rR|13|^^^^MXD#^1| 0.6|10*3/uL||N|||| ||20240723172452\rR|14|^^^^NEUT#^1| 3.4|10*3/uL||N|||| ||20240723172452\rR|15|^^^^RDW-SD^1| 38.5|fL||N|||| ||20240723172452\rR|16|^^^^RDW-CV^1| 11.8|%||N|||| ||20240723172452\rR|17|^^^^PDW^1| 12.8|fL||N|||| ||20240723172452\rR|18|^^^^MPV^1| 10.2|fL||N|||| ||20240723172452\rR|19|^^^^P-LCR^1| 26.9|%||N|||| ||20240723172452\rR|20|^^^^PCT^1| 0.17|%||N|||| ||20240723172452\rL|1|N\r\u0003", + "version": "1.0.0" } } \ No newline at end of file diff --git a/src/senaite/astm/tests/data/envelopes/yumizen_h500.json b/src/senaite/astm/tests/data/envelopes/yumizen_h500.json index 13824a5..d8b7c8d 100644 --- a/src/senaite/astm/tests/data/envelopes/yumizen_h500.json +++ b/src/senaite/astm/tests/data/envelopes/yumizen_h500.json @@ -198,6 +198,7 @@ "weight": null } ], + "Q": [], "R": [ { "abnormal_flag": "N", @@ -832,6 +833,10 @@ ], "metadata": { "astm": "\u00021H|\\^&|||H500^910YOXH02826^2.2.2.2b|||||||Q|LIS2-A2|20230329110749\r\u0003BC\n\u00022P|1|||||||||||||||||||||||||||||||||||\r\u000333\n\u00023O|1|PX440N||^^^DIF|R|20230329110631|||||||||CTRL^^CTRL MEDIUM||||||||||F|||||\r\u000326\n\u00024C|1|I|CONTROL_FAILED^^PLT_ABOVE_TOLERANCE|I\r\u0003D3\n\u00025C|2|I|ABXdifftrol N|G\r\u0003DD\n\u00021M|1|HISTOGRAM|RBC/PLT|RbcAlongRes|FLOATLE-stream/deflate:base64^Y2AAgW5nMNVg6gIkHUBMAA==|FLOATLE-stream/deflate:base64^zdV/aJR1HMDxb/kj6aatsmvWrDvaluYdTjfdJdqe5/u5wmpbM7O8yn7YZWrSVbOsa9njtbtNR7QhynJHHKSuqNE4QkQ4vJDiCIvhFWmhkyHijqBiG4VI9H52FxzLP6L+6eDN89yz772+3+cHz5SyP50ysbGW+JUyDKVWaqUy5Mwf56BS2yXo7qwPuh2GM9lssG+kRvrZHzRC4VG+O8zuaLmZGvGaucZ6kzGmONeboXCL2RSKmIw3w9ndjImbfXUHGNdvZnsPMfaoyVyMHzS9wVP8ZtgMZHL8btSMeS7x2yt1IDNd4+tQ+Bodzl6vY54yzVw6fsal++oqdbJrvmZendGLdbZ3qR4aW6ZZgx4/KNo+F0egQbMe7Xas0d5gQPtS6zRr002hDdibsUPYLdjbsFtxLP4WwWrj7zG8DsZ0Yr7DuHdxuxm7G3sv43vw97GeOHO8z5oSzPMB6zrAXH2s7SPm+5j19TPnAHYS+zPsQ9iHsY9gp7CPYn+OfQz7i4nrH/N8hX0c+xvsQewT2N9hf499CvtH7NPYQ9jD2Oewz2NfwM5h/4T9M/av2KPY49i/YV/EvoT9B79R4ghcIVxn6Y5OkWzvVHEmpwnXXOJnrpKhsRnidlzNvXdIX12J5Bpnijc4S7gXkuwqlfGD14ovdZ1wXyQ1MlvsZ0acNwr3SDJ6DvZN2Ddjl2PPxb4F+1ZsF7Yb+zbsCuxK7Crs27HnYc/HvgN7AbYH24u9ELsaexH2Yuwa7FrsJdhLseuwfdh3Yi/DXo69Avsu7HpsA9vE1tiC7ce+G/se7JXY92Lfh30/dgN2I3YT9gPYzdirsB/EXo29Bvth7Eew12IHsB/Ffgz7cex12E9gP4n9NPZ67Gewg9jPYm/Afg57I/Ym7M3Yz2NvwX4BO4T9IvZL2C9jt2BvxX4F+1XsbdivYYex38BuxX4Tezv2W+xbkvva4vsO8Z7bwbGIhC5GmPNtSZa2MW+bjFdFmTsqvhUx5o9JeHU7a2iX1KYO1tEhytrJWnb99Q74jx+rPv8esbPfMRYlKE1nCykzX2lRqmhb3OXGV9NTZFEXfUiD9hie+TIyaAt1UZJ+oOmcXxX5Kcg5R+k99j+lL+k030dpql+lZ/mVVcbWRZV+ZXj4voj9Wv/Eu9POqGG7kPfoAo5X0FyO3cC2hO00/vY71nncb9keoyT7+9nuoXb2X8+vQ60iTTVUQXNoJk2hMdY9TCfpBB2nNB2hAfqE9lOC4rSPeibeV/lz30VtFKFW2lq4LhtpLT1EDTr/f8C+ZsuplnxUTR6aRxXkKlROs6mEZuj8Nf+Fa3+BThbuQ5oOFxoo3J9EoZ5C9n3rKGQVZd/XZjIK2ffaVdTkZ+Rs0XOV/gclirImZUxK/Yvs5///0t8+lzlHw/gT\r\u000342\n\u00021M|2|HISTOGRAM|RBC/PLT|PltAlongRes|FLOATLE-stream/deflate:base64^Y2AAAQ4nMMXQA6IdgMghPS3IQVTvkeOqjyaOELkGe5AcAA==|FLOATLE-stream/deflate:base64^1dR/TNR1HMfxE24GNQomjZGjiVM0a0QsyaT4fl7fmzEGDRyNocOGyZKxzsUUyRh4oikBiUEqBsUlhGhQZ57yI6DDUH4YBERIUAdB/DAoCA2hzup1+HW7mGvZf9547hjf7+c+bx5f+KhU1peDPPemyuS7TvAbtgQqlZ5N4uY16892a7TmFQEW16MBdWt7AjSG+yUvjY/k0LVeGovdLo0kp0uRgXlSk3Op5N9TLZUWtkgeWrOU5TchWddXxjiI+CZnscrbXQxme4q864+IF6J8hVPdWnFhuUakpAeLpybCxUR4lCipiBGbPbTCPTVBdAwn8/pesTTyAO/JED3VWbwvR+R45vLefBGyX8/7i4R6rIRrykRN6GmuOyt2Giu5tkZ4u58X6cEXxUhys9AYWkXBQIewuF4WkYG9wrirT7iUDgqteUQ0OY8LL82kSE24JswnZoR/j4WfrcLrBXYoqVCjq30h1GMO8FXfh80eTsjyewA1oS4Yi10E99QHEZjnhp1GdxS1LEbHsAeshlcXLcXQimXo9vfCpdCVqN2yCobEx1CY4Y0jBT5IO+OLpIYnsa3XD9ETaxBu74/n3J7F049K4BceDtfAZes62CcFYvpgEK4cD0bvuefR2hyKOvN6GKfCUbwwAsceikSm90akyFGIj3gRMXHRiEh5CUHZMXim+GU8XhULz9Y4uA68gnumt2HWMR7jHtvR90QC2tclon7DLpRrk3BqTzLyD+9GQ6eOn78HVaOp3GMvyiz7uM9+6J0PcK80ZC97k/ul4401GdwzE6+FvMV9DyIuOot7H8KmHW9z/2yEpeVwhncg5x/mHEew2nCUs+RiZf0xzvMuFnfncaZ8OI2/x7nen/ubm3X8gG7HOV8h7Yo444f0K+acJ2hYwllP0vEU5/2IlqWcuYyeH3PuT2hqQNbJ03T9FKk1Z2hrxI72s/Q9h61D5TSuwMbZSjp/hhCnalrXQPKspffn8F1tonkdlgedp/sXcNtUT/sLuDf+Iv0bcGNfI59BEyZym/kcLmGg9Es+ixZ8Y2rl8/iKZm00a6dZB82+plknzbpodplm3TT7lmY9NOul2Xc0+55mZpr10ayfZj/QbIBmgzT7kWZDNBum2QjNRml2hWY/0WyMZuM0+5lmv8z9j846/kqzKZpdpdk1mv1Gs2maXafZDM1mafY7zf6gmYVmN2j2J83+4nWV3NCpkus3LJDrzAvk2i12ctWonVyutZeNU/ayIVEtl1nUyhlxpy+d9M9UyvkilKznjZ6ZWD9zxs2zx4eFsWimY2ksVzmTDEoVzMQaWRvrVupno0qT85r5j9muGbWp36Zb+7XZ1KhkUqqw6dbcJUp6JevvdcimNCWd0qs2Rc8rzCYxLx+blszL+Tap5tUv/l+mO0z/L+luk7gLU93V/Q0=\r\u0003D3\n\u00021M|3|MATRIX|LMNE|LMNEResAbs|FLOATLE-stream/deflate:base64^Y2AAggf/XRjgtIMDiAkA|FLOATLE-stream/deflate:base64^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\r\u000359\n\u00024M|4|REAGENT|CLEANER\\DILUENT\\LYSE|221114I1*^20230317000000^20230617\\220729H1^20230322000000^20230729\\221026M11^20230327000000^20230527\r\u0003DB\n\u00025R|1|^^^MCV^787-2|90.6|um3|84.0 - 94.0^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003EB\n\u00026R|2|^^^NEU#^751-8|4.20|10E3/uL|3.03 - 4.83^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003B6\n\u00027R|3|^^^NEU%^770-8|50.6|%|37.4 - 57.4^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u000325\n\u00020R|4|^^^RDW-CV^788-0|11.8|%|10.5 - 18.5^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003BB\n\u00021R|5|^^^MPV^32623-1|9.2|um3|7.3 - 11.3^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003E3\n\u00022R|6|^^^RBC^789-8|4.58|10E6/uL|4.32 - 4.72^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u00039C\n\u00023R|7|^^^MON#^742-7|0.27|10E3/uL|0.00 - 0.74^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003B2\n\u00024R|8|^^^PLT^777-3|308|10E3/uL|231 - 291^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u00031A\n\u00025R|9|^^^WBC^6690-2|8.30|10E3/uL|7.30 - 9.30^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003C5\n\u00026R|10|^^^MON%^5905-5|3.3|%|0.0 - 8.8^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003E3\n\u00027R|11|^^^LYM#^731-0|3.29|10E3/uL|2.79 - 4.19^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003FA\n\u00020R|12|^^^HGB^718-7|13.3|g/dL|12.7 - 13.7^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u000327\n\u00021R|13|^^^LYM%^736-9|39.7|%|34.0 - 50.0^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u000353\n\u00022R|14|^^^RDW-SD^21000-5|47.0|um3|41.0 - 57.0^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u00032B\n\u00023R|15|^^^BAS%^706-2|1.4|%|0.0 - 5.0^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u00038C\n\u00024R|16|^^^BAS#^704-7|0.12|10E3/uL|0.00 - 0.42^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003C2\n\u00025R|17|^^^MCH^785-6|29.0|pg|27.2 - 31.2^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003CC\n\u00026R|18|^^^MCHC^786-4|32.0|g/dL|29.8 - 35.8^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u00038B\n\u00027R|19|^^^HCT^4544-3|41.4|%|38.2 - 42.2^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u000351\n\u00020R|20|^^^EOS#^711-2|0.42|10E3/uL|0.02 - 0.60^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003C8\n\u00021R|21|^^^EOS%^713-8|5.0|%|0.3 - 7.1^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003A2\n\u00022L|1|N\r\u000305", - "lis2a": "H|\\^&|||H500^910YOXH02826^2.2.2.2b|||||||Q|LIS2-A2|20230329110749\r\u0003P|1|||||||||||||||||||||||||||||||||||\r\u0003O|1|PX440N||^^^DIF|R|20230329110631|||||||||CTRL^^CTRL MEDIUM||||||||||F|||||\r\u0003C|1|I|CONTROL_FAILED^^PLT_ABOVE_TOLERANCE|I\r\u0003C|2|I|ABXdifftrol N|G\r\u0003M|1|HISTOGRAM|RBC/PLT|RbcAlongRes|FLOATLE-stream/deflate:base64^Y2AAgW5nMNVg6gIkHUBMAA==|FLOATLE-stream/deflate:base64^zdV/aJR1HMDxb/kj6aatsmvWrDvaluYdTjfdJdqe5/u5wmpbM7O8yn7YZWrSVbOsa9njtbtNR7QhynJHHKSuqNE4QkQ4vJDiCIvhFWmhkyHijqBiG4VI9H52FxzLP6L+6eDN89yz772+3+cHz5SyP50ysbGW+JUyDKVWaqUy5Mwf56BS2yXo7qwPuh2GM9lssG+kRvrZHzRC4VG+O8zuaLmZGvGaucZ6kzGmONeboXCL2RSKmIw3w9ndjImbfXUHGNdvZnsPMfaoyVyMHzS9wVP8ZtgMZHL8btSMeS7x2yt1IDNd4+tQ+Bodzl6vY54yzVw6fsal++oqdbJrvmZendGLdbZ3qR4aW6ZZgx4/KNo+F0egQbMe7Xas0d5gQPtS6zRr002hDdibsUPYLdjbsFtxLP4WwWrj7zG8DsZ0Yr7DuHdxuxm7G3sv43vw97GeOHO8z5oSzPMB6zrAXH2s7SPm+5j19TPnAHYS+zPsQ9iHsY9gp7CPYn+OfQz7i4nrH/N8hX0c+xvsQewT2N9hf499CvtH7NPYQ9jD2Oewz2NfwM5h/4T9M/av2KPY49i/YV/EvoT9B79R4ghcIVxn6Y5OkWzvVHEmpwnXXOJnrpKhsRnidlzNvXdIX12J5Bpnijc4S7gXkuwqlfGD14ovdZ1wXyQ1MlvsZ0acNwr3SDJ6DvZN2Ddjl2PPxb4F+1ZsF7Yb+zbsCuxK7Crs27HnYc/HvgN7AbYH24u9ELsaexH2Yuwa7FrsJdhLseuwfdh3Yi/DXo69Avsu7HpsA9vE1tiC7ce+G/se7JXY92Lfh30/dgN2I3YT9gPYzdirsB/EXo29Bvth7Eew12IHsB/Ffgz7cex12E9gP4n9NPZ67Gewg9jPYm/Afg57I/Ym7M3Yz2NvwX4BO4T9IvZL2C9jt2BvxX4F+1XsbdivYYex38BuxX4Tezv2W+xbkvva4vsO8Z7bwbGIhC5GmPNtSZa2MW+bjFdFmTsqvhUx5o9JeHU7a2iX1KYO1tEhytrJWnb99Q74jx+rPv8esbPfMRYlKE1nCykzX2lRqmhb3OXGV9NTZFEXfUiD9hie+TIyaAt1UZJ+oOmcXxX5Kcg5R+k99j+lL+k030dpql+lZ/mVVcbWRZV+ZXj4voj9Wv/Eu9POqGG7kPfoAo5X0FyO3cC2hO00/vY71nncb9keoyT7+9nuoXb2X8+vQ60iTTVUQXNoJk2hMdY9TCfpBB2nNB2hAfqE9lOC4rSPeibeV/lz30VtFKFW2lq4LhtpLT1EDTr/f8C+ZsuplnxUTR6aRxXkKlROs6mEZuj8Nf+Fa3+BThbuQ5oOFxoo3J9EoZ5C9n3rKGQVZd/XZjIK2ffaVdTkZ+Rs0XOV/gclirImZUxK/Yvs5///0t8+lzlHw/gT\r\u0003M|2|HISTOGRAM|RBC/PLT|PltAlongRes|FLOATLE-stream/deflate:base64^Y2AAAQ4nMMXQA6IdgMghPS3IQVTvkeOqjyaOELkGe5AcAA==|FLOATLE-stream/deflate:base64^1dR/TNR1HMfxE24GNQomjZGjiVM0a0QsyaT4fl7fmzEGDRyNocOGyZKxzsUUyRh4oikBiUEqBsUlhGhQZ57yI6DDUH4YBERIUAdB/DAoCA2hzup1+HW7mGvZf9547hjf7+c+bx5f+KhU1peDPPemyuS7TvAbtgQqlZ5N4uY16892a7TmFQEW16MBdWt7AjSG+yUvjY/k0LVeGovdLo0kp0uRgXlSk3Op5N9TLZUWtkgeWrOU5TchWddXxjiI+CZnscrbXQxme4q864+IF6J8hVPdWnFhuUakpAeLpybCxUR4lCipiBGbPbTCPTVBdAwn8/pesTTyAO/JED3VWbwvR+R45vLefBGyX8/7i4R6rIRrykRN6GmuOyt2Giu5tkZ4u58X6cEXxUhys9AYWkXBQIewuF4WkYG9wrirT7iUDgqteUQ0OY8LL82kSE24JswnZoR/j4WfrcLrBXYoqVCjq30h1GMO8FXfh80eTsjyewA1oS4Yi10E99QHEZjnhp1GdxS1LEbHsAeshlcXLcXQimXo9vfCpdCVqN2yCobEx1CY4Y0jBT5IO+OLpIYnsa3XD9ETaxBu74/n3J7F049K4BceDtfAZes62CcFYvpgEK4cD0bvuefR2hyKOvN6GKfCUbwwAsceikSm90akyFGIj3gRMXHRiEh5CUHZMXim+GU8XhULz9Y4uA68gnumt2HWMR7jHtvR90QC2tclon7DLpRrk3BqTzLyD+9GQ6eOn78HVaOp3GMvyiz7uM9+6J0PcK80ZC97k/ul4401GdwzE6+FvMV9DyIuOot7H8KmHW9z/2yEpeVwhncg5x/mHEew2nCUs+RiZf0xzvMuFnfncaZ8OI2/x7nen/ubm3X8gG7HOV8h7Yo444f0K+acJ2hYwllP0vEU5/2IlqWcuYyeH3PuT2hqQNbJ03T9FKk1Z2hrxI72s/Q9h61D5TSuwMbZSjp/hhCnalrXQPKspffn8F1tonkdlgedp/sXcNtUT/sLuDf+Iv0bcGNfI59BEyZym/kcLmGg9Es+ixZ8Y2rl8/iKZm00a6dZB82+plknzbpodplm3TT7lmY9NOul2Xc0+55mZpr10ayfZj/QbIBmgzT7kWZDNBum2QjNRml2hWY/0WyMZuM0+5lmv8z9j846/kqzKZpdpdk1mv1Gs2maXafZDM1mafY7zf6gmYVmN2j2J83+4nWV3NCpkus3LJDrzAvk2i12ctWonVyutZeNU/ayIVEtl1nUyhlxpy+d9M9UyvkilKznjZ6ZWD9zxs2zx4eFsWimY2ksVzmTDEoVzMQaWRvrVupno0qT85r5j9muGbWp36Zb+7XZ1KhkUqqw6dbcJUp6JevvdcimNCWd0qs2Rc8rzCYxLx+blszL+Tap5tUv/l+mO0z/L+luk7gLU93V/Q0=\r\u0003M|3|MATRIX|LMNE|LMNEResAbs|FLOATLE-stream/deflate:base64^Y2AAggf/XRjgtIMDiAkA|FLOATLE-stream/deflate:base64^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\r\u0003M|4|REAGENT|CLEANER\\DILUENT\\LYSE|221114I1*^20230317000000^20230617\\220729H1^20230322000000^20230729\\221026M11^20230327000000^20230527\r\u0003R|1|^^^MCV^787-2|90.6|um3|84.0 - 94.0^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|2|^^^NEU#^751-8|4.20|10E3/uL|3.03 - 4.83^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|3|^^^NEU%^770-8|50.6|%|37.4 - 57.4^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|4|^^^RDW-CV^788-0|11.8|%|10.5 - 18.5^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|5|^^^MPV^32623-1|9.2|um3|7.3 - 11.3^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|6|^^^RBC^789-8|4.58|10E6/uL|4.32 - 4.72^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|7|^^^MON#^742-7|0.27|10E3/uL|0.00 - 0.74^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|8|^^^PLT^777-3|308|10E3/uL|231 - 291^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|9|^^^WBC^6690-2|8.30|10E3/uL|7.30 - 9.30^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|10|^^^MON%^5905-5|3.3|%|0.0 - 8.8^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|11|^^^LYM#^731-0|3.29|10E3/uL|2.79 - 4.19^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|12|^^^HGB^718-7|13.3|g/dL|12.7 - 13.7^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|13|^^^LYM%^736-9|39.7|%|34.0 - 50.0^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|14|^^^RDW-SD^21000-5|47.0|um3|41.0 - 57.0^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|15|^^^BAS%^706-2|1.4|%|0.0 - 5.0^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|16|^^^BAS#^704-7|0.12|10E3/uL|0.00 - 0.42^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|17|^^^MCH^785-6|29.0|pg|27.2 - 31.2^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|18|^^^MCHC^786-4|32.0|g/dL|29.8 - 35.8^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|19|^^^HCT^4544-3|41.4|%|38.2 - 42.2^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|20|^^^EOS#^711-2|0.42|10E3/uL|0.02 - 0.60^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|21|^^^EOS%^713-8|5.0|%|0.3 - 7.1^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003L|1|N\r\u0003" + "envelope_version": "1.1", + "header_rx": ".*H5[0,5]0\\^", + "hl7": "", + "lis2a": "H|\\^&|||H500^910YOXH02826^2.2.2.2b|||||||Q|LIS2-A2|20230329110749\r\u0003P|1|||||||||||||||||||||||||||||||||||\r\u0003O|1|PX440N||^^^DIF|R|20230329110631|||||||||CTRL^^CTRL MEDIUM||||||||||F|||||\r\u0003C|1|I|CONTROL_FAILED^^PLT_ABOVE_TOLERANCE|I\r\u0003C|2|I|ABXdifftrol N|G\r\u0003M|1|HISTOGRAM|RBC/PLT|RbcAlongRes|FLOATLE-stream/deflate:base64^Y2AAgW5nMNVg6gIkHUBMAA==|FLOATLE-stream/deflate:base64^zdV/aJR1HMDxb/kj6aatsmvWrDvaluYdTjfdJdqe5/u5wmpbM7O8yn7YZWrSVbOsa9njtbtNR7QhynJHHKSuqNE4QkQ4vJDiCIvhFWmhkyHijqBiG4VI9H52FxzLP6L+6eDN89yz772+3+cHz5SyP50ysbGW+JUyDKVWaqUy5Mwf56BS2yXo7qwPuh2GM9lssG+kRvrZHzRC4VG+O8zuaLmZGvGaucZ6kzGmONeboXCL2RSKmIw3w9ndjImbfXUHGNdvZnsPMfaoyVyMHzS9wVP8ZtgMZHL8btSMeS7x2yt1IDNd4+tQ+Bodzl6vY54yzVw6fsal++oqdbJrvmZendGLdbZ3qR4aW6ZZgx4/KNo+F0egQbMe7Xas0d5gQPtS6zRr002hDdibsUPYLdjbsFtxLP4WwWrj7zG8DsZ0Yr7DuHdxuxm7G3sv43vw97GeOHO8z5oSzPMB6zrAXH2s7SPm+5j19TPnAHYS+zPsQ9iHsY9gp7CPYn+OfQz7i4nrH/N8hX0c+xvsQewT2N9hf499CvtH7NPYQ9jD2Oewz2NfwM5h/4T9M/av2KPY49i/YV/EvoT9B79R4ghcIVxn6Y5OkWzvVHEmpwnXXOJnrpKhsRnidlzNvXdIX12J5Bpnijc4S7gXkuwqlfGD14ovdZ1wXyQ1MlvsZ0acNwr3SDJ6DvZN2Ddjl2PPxb4F+1ZsF7Yb+zbsCuxK7Crs27HnYc/HvgN7AbYH24u9ELsaexH2Yuwa7FrsJdhLseuwfdh3Yi/DXo69Avsu7HpsA9vE1tiC7ce+G/se7JXY92Lfh30/dgN2I3YT9gPYzdirsB/EXo29Bvth7Eew12IHsB/Ffgz7cex12E9gP4n9NPZ67Gewg9jPYm/Afg57I/Ym7M3Yz2NvwX4BO4T9IvZL2C9jt2BvxX4F+1XsbdivYYex38BuxX4Tezv2W+xbkvva4vsO8Z7bwbGIhC5GmPNtSZa2MW+bjFdFmTsqvhUx5o9JeHU7a2iX1KYO1tEhytrJWnb99Q74jx+rPv8esbPfMRYlKE1nCykzX2lRqmhb3OXGV9NTZFEXfUiD9hie+TIyaAt1UZJ+oOmcXxX5Kcg5R+k99j+lL+k030dpql+lZ/mVVcbWRZV+ZXj4voj9Wv/Eu9POqGG7kPfoAo5X0FyO3cC2hO00/vY71nncb9keoyT7+9nuoXb2X8+vQ60iTTVUQXNoJk2hMdY9TCfpBB2nNB2hAfqE9lOC4rSPeibeV/lz30VtFKFW2lq4LhtpLT1EDTr/f8C+ZsuplnxUTR6aRxXkKlROs6mEZuj8Nf+Fa3+BThbuQ5oOFxoo3J9EoZ5C9n3rKGQVZd/XZjIK2ffaVdTkZ+Rs0XOV/gclirImZUxK/Yvs5///0t8+lzlHw/gT\r\u0003M|2|HISTOGRAM|RBC/PLT|PltAlongRes|FLOATLE-stream/deflate:base64^Y2AAAQ4nMMXQA6IdgMghPS3IQVTvkeOqjyaOELkGe5AcAA==|FLOATLE-stream/deflate:base64^1dR/TNR1HMfxE24GNQomjZGjiVM0a0QsyaT4fl7fmzEGDRyNocOGyZKxzsUUyRh4oikBiUEqBsUlhGhQZ57yI6DDUH4YBERIUAdB/DAoCA2hzup1+HW7mGvZf9547hjf7+c+bx5f+KhU1peDPPemyuS7TvAbtgQqlZ5N4uY16892a7TmFQEW16MBdWt7AjSG+yUvjY/k0LVeGovdLo0kp0uRgXlSk3Op5N9TLZUWtkgeWrOU5TchWddXxjiI+CZnscrbXQxme4q864+IF6J8hVPdWnFhuUakpAeLpybCxUR4lCipiBGbPbTCPTVBdAwn8/pesTTyAO/JED3VWbwvR+R45vLefBGyX8/7i4R6rIRrykRN6GmuOyt2Giu5tkZ4u58X6cEXxUhys9AYWkXBQIewuF4WkYG9wrirT7iUDgqteUQ0OY8LL82kSE24JswnZoR/j4WfrcLrBXYoqVCjq30h1GMO8FXfh80eTsjyewA1oS4Yi10E99QHEZjnhp1GdxS1LEbHsAeshlcXLcXQimXo9vfCpdCVqN2yCobEx1CY4Y0jBT5IO+OLpIYnsa3XD9ETaxBu74/n3J7F049K4BceDtfAZes62CcFYvpgEK4cD0bvuefR2hyKOvN6GKfCUbwwAsceikSm90akyFGIj3gRMXHRiEh5CUHZMXim+GU8XhULz9Y4uA68gnumt2HWMR7jHtvR90QC2tclon7DLpRrk3BqTzLyD+9GQ6eOn78HVaOp3GMvyiz7uM9+6J0PcK80ZC97k/ul4401GdwzE6+FvMV9DyIuOot7H8KmHW9z/2yEpeVwhncg5x/mHEew2nCUs+RiZf0xzvMuFnfncaZ8OI2/x7nen/ubm3X8gG7HOV8h7Yo444f0K+acJ2hYwllP0vEU5/2IlqWcuYyeH3PuT2hqQNbJ03T9FKk1Z2hrxI72s/Q9h61D5TSuwMbZSjp/hhCnalrXQPKspffn8F1tonkdlgedp/sXcNtUT/sLuDf+Iv0bcGNfI59BEyZym/kcLmGg9Es+ixZ8Y2rl8/iKZm00a6dZB82+plknzbpodplm3TT7lmY9NOul2Xc0+55mZpr10ayfZj/QbIBmgzT7kWZDNBum2QjNRml2hWY/0WyMZuM0+5lmv8z9j846/kqzKZpdpdk1mv1Gs2maXafZDM1mafY7zf6gmYVmN2j2J83+4nWV3NCpkus3LJDrzAvk2i12ctWonVyutZeNU/ayIVEtl1nUyhlxpy+d9M9UyvkilKznjZ6ZWD9zxs2zx4eFsWimY2ksVzmTDEoVzMQaWRvrVupno0qT85r5j9muGbWp36Zb+7XZ1KhkUqqw6dbcJUp6JevvdcimNCWd0qs2Rc8rzCYxLx+blszL+Tap5tUv/l+mO0z/L+luk7gLU93V/Q0=\r\u0003M|3|MATRIX|LMNE|LMNEResAbs|FLOATLE-stream/deflate:base64^Y2AAggf/XRjgtIMDiAkA|FLOATLE-stream/deflate:base64^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\r\u0003M|4|REAGENT|CLEANER\\DILUENT\\LYSE|221114I1*^20230317000000^20230617\\220729H1^20230322000000^20230729\\221026M11^20230327000000^20230527\r\u0003R|1|^^^MCV^787-2|90.6|um3|84.0 - 94.0^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|2|^^^NEU#^751-8|4.20|10E3/uL|3.03 - 4.83^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|3|^^^NEU%^770-8|50.6|%|37.4 - 57.4^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|4|^^^RDW-CV^788-0|11.8|%|10.5 - 18.5^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|5|^^^MPV^32623-1|9.2|um3|7.3 - 11.3^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|6|^^^RBC^789-8|4.58|10E6/uL|4.32 - 4.72^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|7|^^^MON#^742-7|0.27|10E3/uL|0.00 - 0.74^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|8|^^^PLT^777-3|308|10E3/uL|231 - 291^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|9|^^^WBC^6690-2|8.30|10E3/uL|7.30 - 9.30^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|10|^^^MON%^5905-5|3.3|%|0.0 - 8.8^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|11|^^^LYM#^731-0|3.29|10E3/uL|2.79 - 4.19^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|12|^^^HGB^718-7|13.3|g/dL|12.7 - 13.7^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|13|^^^LYM%^736-9|39.7|%|34.0 - 50.0^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|14|^^^RDW-SD^21000-5|47.0|um3|41.0 - 57.0^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|15|^^^BAS%^706-2|1.4|%|0.0 - 5.0^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|16|^^^BAS#^704-7|0.12|10E3/uL|0.00 - 0.42^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|17|^^^MCH^785-6|29.0|pg|27.2 - 31.2^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|18|^^^MCHC^786-4|32.0|g/dL|29.8 - 35.8^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|19|^^^HCT^4544-3|41.4|%|38.2 - 42.2^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|20|^^^EOS#^711-2|0.42|10E3/uL|0.02 - 0.60^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003R|21|^^^EOS%^713-8|5.0|%|0.3 - 7.1^REFERENCE_RANGE|N||F||MATYL^^USER|20230329110631||\r\u0003L|1|N\r\u0003", + "version": "1.0.0" } } \ No newline at end of file diff --git a/src/senaite/astm/tests/data/hl7/hemoscreen_fresh_blood.hl7 b/src/senaite/astm/tests/data/hl7/hemoscreen_fresh_blood.hl7 new file mode 100644 index 0000000..df3372a --- /dev/null +++ b/src/senaite/astm/tests/data/hl7/hemoscreen_fresh_blood.hl7 @@ -0,0 +1,23 @@ +MSH|^~\&|HemoScreen|PixCell|||20231224174110||ORU^R01|0|P|2.4 +PID||35 +OBR||||OBS|||20231210111800 +OBX|0|NM|WBC||11.7|10*3/uL|||||F|||20231210111800|0000000-0001-HS +OBX|1|NM|RBC||4.04|10*6/uL|||||F|||20231210111800|0000000-0001-HS +OBX|2|NM|HGB||14.25|g/dL|||||F|||20231210111800|0000000-0001-HS +OBX|3|NM|HCT||34.44|%|||||F|||20231210111800|0000000-0001-HS +OBX|4|NM|MCV||85.25|fL|||||F|||20231210111800|0000000-0001-HS +OBX|5|NM|MCH||35.21|pg|||||F|||20231210111800|0000000-0001-HS +OBX|6|NM|MCHC||0.00|g/dL|||||F|||20231210111800|0000000-0001-HS +OBX|7|NM|RDW||12.8|%|||||F|||20231210111800|0000000-0001-HS +OBX|8|NM|PLT||120|10*3/uL|||||F|||20231210111800|0000000-0001-HS +OBX|9|NM|MPV||0|fL|||||F|||20231210111800|0000000-0001-HS +OBX|10|NM|NEU#||8.02|10*3/uL|||||F|||20231210111800|0000000-0001-HS +OBX|11|NM|LYM#||2.28|10*3/uL|||||F|||20231210111800|0000000-0001-HS +OBX|12|NM|MON#||1.25|10*3/uL|||||F|||20231210111800|0000000-0001-HS +OBX|13|NM|EOS#||0.14|10*3/uL|||||F|||20231210111800|0000000-0001-HS +OBX|14|NM|BAS#||0.00|10*3/uL|||||F|||20231210111800|0000000-0001-HS +OBX|15|NM|NEU%||68.6|%|||||F|||20231210111800|0000000-0001-HS +OBX|16|NM|LYM%||19.5|%|||||F|||20231210111800|0000000-0001-HS +OBX|17|NM|MON%||10.7|%|||||F|||20231210111800|0000000-0001-HS +OBX|18|NM|EOS%||1.20|%|||||F|||20231210111800|0000000-0001-HS +OBX|19|NM|BAS%||0.00|%|||||F|||20231210111800|0000000-0001-HS diff --git a/src/senaite/astm/tests/data/hl7/hemoscreen_proficiency.hl7 b/src/senaite/astm/tests/data/hl7/hemoscreen_proficiency.hl7 new file mode 100644 index 0000000..c48e1f8 --- /dev/null +++ b/src/senaite/astm/tests/data/hl7/hemoscreen_proficiency.hl7 @@ -0,0 +1,23 @@ +MSH|^~\&|HemoScreen|PixCell|||20231224173346||ORU^R01|2|P|2.4 +PID||2 +OBR||||PRF|||20231210112500 +OBX|0|NM|WBC||11.0|10*3/uL|||||F|||20231210112500|0000000-0001-HS +OBX|1|NM|RBC||10.3|10*6/uL|||||F|||20231210112500|0000000-0001-HS +OBX|2|NM|HGB||39.99|g/dL|||||F|||20231210112500|0000000-0001-HS +OBX|3|NM|HCT||87.95|%|||||F|||20231210112500|0000000-0001-HS +OBX|4|NM|MCV||85.25|fL|||||F|||20231210112500|0000000-0001-HS +OBX|5|NM|MCH||38.76|pg|||||F|||20231210112500|0000000-0001-HS +OBX|6|NM|MCHC||0.00|g/dL|||||F|||20231210112500|0000000-0001-HS +OBX|7|NM|RDW||12.8|%|||||F|||20231210112500|0000000-0001-HS +OBX|8|NM|PLT||0.00|10*3/uL|||||F|||20231210112500|0000000-0001-HS +OBX|9|NM|MPV||0.00|fL|||||F|||20231210112500|0000000-0001-HS +OBX|10|NM|NEU#||10.4|10*3/uL|||||F|||20231210112500|0000000-0001-HS +OBX|11|NM|LYM#||0.28|10*3/uL|||||F|||20231210112500|0000000-0001-HS +OBX|12|NM|MON#||0.00|10*3/uL|||||F|||20231210112500|0000000-0001-HS +OBX|13|NM|EOS#||0.28|10*3/uL|||||F|||20231210112500|0000000-0001-HS +OBX|14|NM|BAS#||0.13|10*3/uL|||||F|||20231210112500|0000000-0001-HS +OBX|15|NM|NEU%||93.8|%|||||F|||20231210112500|0000000-0001-HS +OBX|16|NM|LYM%||2.50|%|||||F|||20231210112500|0000000-0001-HS +OBX|17|NM|MON%||0.00|%|||||F|||20231210112500|0000000-0001-HS +OBX|18|NM|EOS%||2.50|%|||||F|||20231210112500|0000000-0001-HS +OBX|19|NM|BAS%||1.20|%|||||F|||20231210112500|0000000-0001-HS diff --git a/src/senaite/astm/tests/data/hl7/hemoscreen_quality_control.hl7 b/src/senaite/astm/tests/data/hl7/hemoscreen_quality_control.hl7 new file mode 100644 index 0000000..81cb1e4 --- /dev/null +++ b/src/senaite/astm/tests/data/hl7/hemoscreen_quality_control.hl7 @@ -0,0 +1,23 @@ +MSH|^~\&|HemoScreen|PixCell|||20231224173346||ORU^R01|1|P|2.4 +PID||PIX240205N +OBR||||LQC|||20231210114100 +OBX|0|NM|WBC||11.0|10*3/uL|5.9-9.3||||F|||20231210114100|0000000-0001-HS +OBX|1|NM|RBC||10.3|10*6/uL|4.3-5.5||||F|||20231210114100|0000000-0001-HS +OBX|2|NM|HGB||39.99|g/dL|14.2-17.8||||F|||20231210114100|0000000-0001-HS +OBX|3|NM|HCT||87.95|%|33.5-42.5||||F|||20231210114100|0000000-0001-HS +OBX|4|NM|MCV||85.25|fL|72.5-82.5||||F|||20231210114100|0000000-0001-HS +OBX|5|NM|MCH||38.76|pg|27.7-37.7||||F|||20231210114100|0000000-0001-HS +OBX|6|NM|MCHC||0.00|g/dL|37.1-47.1||||F|||20231210114100|0000000-0001-HS +OBX|7|NM|RDW||12.8|%|12.5-18.5||||F|||20231210114100|0000000-0001-HS +OBX|8|NM|PLT||0.00|10*3/uL|203-293||||F|||20231210114100|0000000-0001-HS +OBX|9|NM|MPV||0.00|fL|8.4-12.4||||F|||20231210114100|0000000-0001-HS +OBX|10|NM|NEU#||10.4|10*3/uL|2.5-3.9||||F|||20231210114100|0000000-0001-HS +OBX|11|NM|LYM#||0.28|10*3/uL|2.5-3.9||||F|||20231210114100|0000000-0001-HS +OBX|12|NM|MON#||0.00|10*3/uL|0.2-1||||F|||20231210114100|0000000-0001-HS +OBX|13|NM|EOS#||0.28|10*3/uL|0-1.2||||F|||20231210114100|0000000-0001-HS +OBX|14|NM|BAS#||0.13|10*3/uL|0-0.2||||F|||20231210114100|0000000-0001-HS +OBX|15|NM|NEU%||93.8|%|34-52||||F|||20231210114100|0000000-0001-HS +OBX|16|NM|LYM%||2.50|%|32.5-50.5||||F|||20231210114100|0000000-0001-HS +OBX|17|NM|MON%||0.00|%|2.5-12.5||||F|||20231210114100|0000000-0001-HS +OBX|18|NM|EOS%||2.50|%|0-15||||F|||20231210114100|0000000-0001-HS +OBX|19|NM|BAS%||1.20|%|0-1||||F|||20231210114100|0000000-0001-HS diff --git a/src/senaite/astm/tests/data/hl7/hemoscreen_with_flags.hl7 b/src/senaite/astm/tests/data/hl7/hemoscreen_with_flags.hl7 new file mode 100644 index 0000000..04d04f4 --- /dev/null +++ b/src/senaite/astm/tests/data/hl7/hemoscreen_with_flags.hl7 @@ -0,0 +1,33 @@ +MSH|^~\&|HemoScreen|PixCell|||20231224172621||ORU^R01|0|P|2.4 +PID||35 +OBR||||OBS|||20231210111800 +OBX|0|NM|WBC||11.7|10*3/uL|||||F|||20231210111800|0000000-0001-HS +OBX|1|NM|RBC||4.04|10*6/uL|||||F|||20231210111800|0000000-0001-HS +OBX|2|ST|HGB||LL|g/dL|||||F|||20231210111800|0000000-0001-HS +OBX|3|NM|HCT||3.44|%|||||F|||20231210111800|0000000-0001-HS +OBX|4|NM|MCV||5.25|fL|||||F|||20231210111800|0000000-0001-HS +OBX|5|NM|MCH||5.21|pg|||||F|||20231210111800|0000000-0001-HS +OBX|6|NM|MCHC||0.00|g/dL|||||F|||20231210111800|0000000-0001-HS +OBX|7|NM|RDW||12.8|%|||||F|||20231210111800|0000000-0001-HS +OBX|8|ST|PLT||LL|10*3/uL|||||F|||20231210111800|0000000-0001-HS +OBX|9|ST|MPV||---|fL|||||F|||20231210111800|0000000-0001-HS +OBX|10|NM|NEU#||8.02|10*3/uL||*|||F|||20231210111800|0000000-0001-HS +NTE|||Abnormal cells may affect marked results +OBX|11|NM|LYM#||2.28|10*3/uL||*|||F|||20231210111800|0000000-0001-HS +NTE|||Abnormal cells may affect marked results +OBX|12|NM|MON#||1.25|10*3/uL||*|||F|||20231210111800|0000000-0001-HS +NTE|||Abnormal cells may affect marked results +OBX|13|NM|EOS#||0.14|10*3/uL||*|||F|||20231210111800|0000000-0001-HS +NTE|||Abnormal cells may affect marked results +OBX|14|NM|BAS#||0.00|10*3/uL||*|||F|||20231210111800|0000000-0001-HS +NTE|||Abnormal cells may affect marked results +OBX|15|NM|NEU%||68.6|%||*|||F|||20231210111800|0000000-0001-HS +NTE|||Abnormal cells may affect marked results +OBX|16|NM|LYM%||19.5|%||*|||F|||20231210111800|0000000-0001-HS +NTE|||Abnormal cells may affect marked results +OBX|17|NM|MON%||10.7|%||*|||F|||20231210111800|0000000-0001-HS +NTE|||Abnormal cells may affect marked results +OBX|18|NM|EOS%||1.20|%||*|||F|||20231210111800|0000000-0001-HS +NTE|||Abnormal cells may affect marked results +OBX|19|NM|BAS%||0.00|%||*|||F|||20231210111800|0000000-0001-HS +NTE|||Abnormal cells may affect marked results diff --git a/src/senaite/astm/tests/sysmex_xp100.py b/src/senaite/astm/tests/sysmex_xp100.py index a14916f..778f3ba 100644 --- a/src/senaite/astm/tests/sysmex_xp100.py +++ b/src/senaite/astm/tests/sysmex_xp100.py @@ -7,7 +7,7 @@ from senaite.astm.constants import ACK from senaite.astm.constants import ENQ from senaite.astm.instruments import sysmex_xp -from senaite.astm.protocol import ASTMProtocol +from senaite.astm.transports.astm.protocol import ASTMProtocol from senaite.astm.tests.base import ASTMTestBase from senaite.astm.wrapper import Wrapper @@ -27,7 +27,7 @@ async def asyncSetUp(self): # Mock transport and protocol objects self.transport = self.get_mock_transport() self.protocol.transport = self.transport - self.mapping = sysmex_xp.get_mapping() + self.mapping = sysmex_xp.INSTRUMENT.record_map def get_mock_transport(self, ip="127.0.0.1", port=12345): transport = MagicMock() diff --git a/src/senaite/astm/tests/test_abbott_afinion2.py b/src/senaite/astm/tests/test_abbott_afinion2.py index c7acda0..3507b0a 100644 --- a/src/senaite/astm/tests/test_abbott_afinion2.py +++ b/src/senaite/astm/tests/test_abbott_afinion2.py @@ -7,7 +7,7 @@ from senaite.astm.constants import ACK from senaite.astm.constants import ENQ from senaite.astm.instruments import abbott_afinion2 -from senaite.astm.protocol import ASTMProtocol +from senaite.astm.transports.astm.protocol import ASTMProtocol from senaite.astm.tests.base import ASTMTestBase from senaite.astm.wrapper import Wrapper @@ -28,7 +28,7 @@ async def asyncSetUp(self): # Mock transport and protocol objects self.transport = self.get_mock_transport() self.protocol.transport = self.transport - self.mapping = abbott_afinion2.get_mapping() + self.mapping = abbott_afinion2.INSTRUMENT.record_map def get_mock_transport(self, ip="127.0.0.1", port=12345): transport = MagicMock() diff --git a/src/senaite/astm/tests/test_astm_protocol.py b/src/senaite/astm/tests/test_astm_protocol.py index 53ad3b2..bfc8fe4 100644 --- a/src/senaite/astm/tests/test_astm_protocol.py +++ b/src/senaite/astm/tests/test_astm_protocol.py @@ -8,7 +8,7 @@ from senaite.astm.constants import ENQ from senaite.astm.constants import EOT from senaite.astm.constants import NAK -from senaite.astm.protocol import ASTMProtocol +from senaite.astm.transports.astm.protocol import ASTMProtocol from senaite.astm.tests.base import ASTMTestBase diff --git a/src/senaite/astm/tests/test_astm_server.py b/src/senaite/astm/tests/test_astm_server.py index cd80271..b7f8373 100644 --- a/src/senaite/astm/tests/test_astm_server.py +++ b/src/senaite/astm/tests/test_astm_server.py @@ -6,7 +6,7 @@ from senaite.astm import logger from senaite.astm.constants import ACK from senaite.astm.constants import ENQ -from senaite.astm.protocol import ASTMProtocol +from senaite.astm.transports.astm.protocol import ASTMProtocol from senaite.astm.tests.base import ASTMTestBase diff --git a/src/senaite/astm/tests/test_cobas_c111.py b/src/senaite/astm/tests/test_cobas_c111.py index 77c48d1..8345c00 100644 --- a/src/senaite/astm/tests/test_cobas_c111.py +++ b/src/senaite/astm/tests/test_cobas_c111.py @@ -7,7 +7,7 @@ from senaite.astm.constants import ACK from senaite.astm.constants import ENQ from senaite.astm.instruments import roche_cobas_c111 -from senaite.astm.protocol import ASTMProtocol +from senaite.astm.transports.astm.protocol import ASTMProtocol from senaite.astm.tests.base import ASTMTestBase from senaite.astm.wrapper import Wrapper @@ -32,7 +32,7 @@ async def asyncSetUp(self): # Mock transport and protocol objects self.transport = self.get_mock_transport() self.protocol.transport = self.transport - self.mapping = roche_cobas_c111.get_mapping() + self.mapping = roche_cobas_c111.INSTRUMENT.record_map def get_mock_transport(self, ip="127.0.0.1", port=12345): transport = MagicMock() diff --git a/src/senaite/astm/tests/test_cobas_c311.py b/src/senaite/astm/tests/test_cobas_c311.py index 8f7d008..29bdda9 100644 --- a/src/senaite/astm/tests/test_cobas_c311.py +++ b/src/senaite/astm/tests/test_cobas_c311.py @@ -7,7 +7,7 @@ from senaite.astm.constants import ACK from senaite.astm.constants import ENQ from senaite.astm.instruments import roche_cobas_c311 -from senaite.astm.protocol import ASTMProtocol +from senaite.astm.transports.astm.protocol import ASTMProtocol from senaite.astm.tests.base import ASTMTestBase from senaite.astm.wrapper import Wrapper @@ -26,7 +26,7 @@ async def asyncSetUp(self): # Mock transport and protocol objects self.transport = self.get_mock_transport() self.protocol.transport = self.transport - self.mapping = roche_cobas_c311.get_mapping() + self.mapping = roche_cobas_c311.INSTRUMENT.record_map def get_mock_transport(self, ip="127.0.0.1", port=12345): transport = MagicMock() diff --git a/src/senaite/astm/tests/test_dca_vantage.py b/src/senaite/astm/tests/test_dca_vantage.py index 7798951..3dacb3e 100644 --- a/src/senaite/astm/tests/test_dca_vantage.py +++ b/src/senaite/astm/tests/test_dca_vantage.py @@ -7,7 +7,7 @@ from senaite.astm.constants import ACK from senaite.astm.constants import ENQ from senaite.astm.instruments import dca_vantage -from senaite.astm.protocol import ASTMProtocol +from senaite.astm.transports.astm.protocol import ASTMProtocol from senaite.astm.tests.base import ASTMTestBase from senaite.astm.wrapper import Wrapper @@ -28,7 +28,7 @@ async def asyncSetUp(self): # Mock transport and protocol objects self.transport = self.get_mock_transport() self.protocol.transport = self.transport - self.mapping = dca_vantage.get_mapping() + self.mapping = dca_vantage.INSTRUMENT.record_map def get_mock_transport(self, ip="127.0.0.1", port=12345): transport = MagicMock() diff --git a/src/senaite/astm/tests/test_end_to_end.py b/src/senaite/astm/tests/test_end_to_end.py index 374afef..085b2f8 100644 --- a/src/senaite/astm/tests/test_end_to_end.py +++ b/src/senaite/astm/tests/test_end_to_end.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- -"""End-to-end smoke test: client → ASTM server → queue → message. +"""End-to-end smoke test: client → ASTM server → pipeline → handler. Locks the full pipeline in one place so a refactor that moves -codec, protocol, wrapper, or queueing around cannot silently change +codec, protocol, wrapper, or pipeline around cannot silently change the payload downstream consumers receive. Per-format checks live here; per-instrument record details live in the instrument-specific tests. @@ -15,8 +15,10 @@ from senaite.astm.constants import ACK from senaite.astm.constants import ENQ from senaite.astm.constants import EOT -from senaite.astm.protocol import ASTMProtocol +from senaite.astm.core.envelope import serialize_envelope from senaite.astm.tests.base import ASTMTestBase +from senaite.astm.transports.astm.protocol import ASTMProtocol +from senaite.astm.wrapper import Wrapper async def send_session(test_case, port, fixture_name): @@ -42,21 +44,37 @@ async def send_session(test_case, port, fixture_name): await writer.wait_closed() -class EndToEndTest(ASTMTestBase): - """Full-pipeline smoke test against a representative fixture.""" +def make_serializing_callback(loop, queue, message_format): + """Build a frame_callback that wraps + serialises + enqueues. - PORT = 7981 + Mirrors the wiring in :mod:`senaite.astm.cli.astm_server` so the + end-to-end test exercises the same path as the production server + without taking a dependency on the CLI module. + """ + def callback(client, frames): + envelope = Wrapper(frames).to_envelope() + payload = serialize_envelope(envelope, message_format) + if message_format == "json": + payload = payload.encode() + loop.call_soon_threadsafe(queue.put_nowait, payload) + return callback + + +class _FormatTestBase(ASTMTestBase): + """Shared scaffolding for the per-format end-to-end tests.""" + + PORT = None + MESSAGE_FORMAT = None async def asyncSetUp(self): - logger.info("\n------------> asyncSetUp e2e") + logger.info("\n------------> asyncSetUp e2e (%s)", + self.MESSAGE_FORMAT) self.queue = asyncio.Queue() - self.loop = asyncio.get_event_loop() + callback = make_serializing_callback( + self.loop, self.queue, self.MESSAGE_FORMAT) self.server = await self.loop.create_server( - lambda: ASTMProtocol( - queue=self.queue, - timeout=15, - message_format="json"), + lambda: ASTMProtocol(frame_callback=callback, timeout=15), host=self.HOST, port=self.PORT) @@ -65,18 +83,18 @@ async def asyncTearDown(self): await self.server.wait_closed() async def send_fixture(self, filename): - """ENQ → data frames → EOT → close. - - The inherited ``communicate`` omits the trailing EOT, so the - protocol never leaves transfer state and never pushes onto - the queue. Queue-based assertions need the full session. - """ await send_session(self, self.PORT, filename) async def collect_one(self, timeout=2.0): - """Pull a single envelope off the queue, with a short bound.""" return await asyncio.wait_for(self.queue.get(), timeout=timeout) + +class EndToEndTest(_FormatTestBase): + """Full-pipeline smoke test against a representative fixture.""" + + PORT = 7981 + MESSAGE_FORMAT = "json" + async def test_json_envelope_reaches_queue(self): """A captured Cobas C111 transcript flows through the server, gets wrapped, and lands on the queue as JSON bytes.""" @@ -112,60 +130,32 @@ async def test_multiple_sessions_queue_independently(self): self.assertTrue(self.queue.empty()) -class LIS2AFormatTest(ASTMTestBase): - """The default message format ("lis2a") emits the LIS2-A - flat string, not the JSON envelope. Lock the format down so a - refactor cannot silently change the wire shape consumers see.""" +class LIS2AFormatTest(_FormatTestBase): + """The "lis2a" format emits the LIS2-A flat string, not the JSON + envelope. Lock the format down so a refactor cannot silently + change the wire shape consumers see.""" PORT = 7982 - - async def asyncSetUp(self): - self.queue = asyncio.Queue() - self.loop = asyncio.get_event_loop() - self.server = await self.loop.create_server( - lambda: ASTMProtocol( - queue=self.queue, - timeout=15, - message_format="lis2a"), - host=self.HOST, - port=self.PORT) - - async def asyncTearDown(self): - self.server.close() - await self.server.wait_closed() + MESSAGE_FORMAT = "lis2a" async def test_lis2a_payload_is_text(self): - await send_session(self, self.PORT, "cobas_c111.txt") - payload = await asyncio.wait_for(self.queue.get(), timeout=2.0) + await self.send_fixture("cobas_c111.txt") + payload = await self.collect_one() # lis2a payload is a decoded string, not bytes self.assertIsInstance(payload, str) # Must contain the H record marker self.assertIn("H|", payload) -class ASTMFormatTest(ASTMTestBase): +class ASTMFormatTest(_FormatTestBase): """The "astm" format emits the original framed payload as text.""" PORT = 7983 - - async def asyncSetUp(self): - self.queue = asyncio.Queue() - self.loop = asyncio.get_event_loop() - self.server = await self.loop.create_server( - lambda: ASTMProtocol( - queue=self.queue, - timeout=15, - message_format="astm"), - host=self.HOST, - port=self.PORT) - - async def asyncTearDown(self): - self.server.close() - await self.server.wait_closed() + MESSAGE_FORMAT = "astm" async def test_astm_payload_is_text(self): - await send_session(self, self.PORT, "cobas_c111.txt") - payload = await asyncio.wait_for(self.queue.get(), timeout=2.0) + await self.send_fixture("cobas_c111.txt") + payload = await self.collect_one() self.assertIsInstance(payload, str) # ASTM payload preserves STX framing self.assertIn("\x02", payload) diff --git a/src/senaite/astm/tests/test_envelope_schema.py b/src/senaite/astm/tests/test_envelope_schema.py new file mode 100644 index 0000000..f98ccd9 --- /dev/null +++ b/src/senaite/astm/tests/test_envelope_schema.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +"""Schema tests for the typed envelope. + +These tests assert that: + +- every checked-in golden snapshot validates against the + :class:`Envelope` schema, +- omitted record-type buckets default to empty lists, +- :data:`ENVELOPE_VERSION` is exposed in the metadata, +- vendor-specific extras (e.g. Roche c111's parsed sender + component) survive a round-trip through the model. +""" + +import glob +import json +import os +import unittest + +from senaite.astm.core.envelope import ENVELOPE_VERSION +from senaite.astm.core.envelope import Envelope + + +SNAPSHOT_DIR = os.path.join( + os.path.dirname(__file__), "data", "envelopes") + + +def _snapshot_paths(): + return sorted(glob.glob(os.path.join(SNAPSHOT_DIR, "*.json"))) + + +class EnvelopeSchemaTest(unittest.TestCase): + """Every checked-in snapshot must round-trip through Envelope.""" + + +def _make_validates(path): + def test(self): + with open(path) as f: + data = json.load(f) + envelope = Envelope.model_validate(data) + self.assertEqual( + envelope.metadata.envelope_version, ENVELOPE_VERSION) + return test + + +for _path in _snapshot_paths(): + _name = os.path.basename(_path).replace(".json", "") + setattr( + EnvelopeSchemaTest, + "test_validates_" + _name, + _make_validates(_path)) + + +class EnvelopeDefaultsTest(unittest.TestCase): + """Defaults are deterministic across the whole envelope shape.""" + + def _minimal(self, **extras): + return Envelope( + metadata={"astm": "", "lis2a": "", **extras}) + + def test_default_envelope_version(self): + envelope = self._minimal() + self.assertEqual( + envelope.metadata.envelope_version, ENVELOPE_VERSION) + + def test_record_buckets_default_to_empty_lists(self): + envelope = self._minimal() + for key in ("H", "P", "O", "R", "C", "M", "L", "Q"): + self.assertEqual( + getattr(envelope, key), [], + "expected envelope.{} to default to []".format(key)) + + def test_metadata_accepts_vendor_extras(self): + envelope = self._minimal(instrument_type="c111", vendor="Roche") + dumped = envelope.model_dump() + self.assertEqual(dumped["metadata"]["instrument_type"], "c111") + self.assertEqual(dumped["metadata"]["vendor"], "Roche") + + def test_metadata_raw_fields_default_to_empty_string(self): + # Pre-1.1 the astm/lis2a fields were required; from 1.1 + # onward each transport populates only its native raw and + # the others default to "". Consumers may still ``.get()`` + # them safely. + envelope = Envelope(metadata={}) + self.assertEqual(envelope.metadata.astm, "") + self.assertEqual(envelope.metadata.lis2a, "") + self.assertEqual(envelope.metadata.hl7, "") + + def test_envelope_version_is_overridable(self): + """Future schema bumps must keep older snapshots loadable + without crashing — version comparison is a consumer concern. + """ + envelope = Envelope( + metadata={"astm": "", "lis2a": "", + "envelope_version": "0.9"}) + self.assertEqual(envelope.metadata.envelope_version, "0.9") diff --git a/src/senaite/astm/tests/test_fields.py b/src/senaite/astm/tests/test_fields.py index 3c13595..1007f8c 100644 --- a/src/senaite/astm/tests/test_fields.py +++ b/src/senaite/astm/tests/test_fields.py @@ -8,7 +8,6 @@ import warnings from senaite.astm import fields -from senaite.astm.compat import u from senaite.astm.mapping import Component from senaite.astm.mapping import Mapping from senaite.astm.tests.base import ASTMTestBase @@ -53,11 +52,18 @@ def test_get_value(self): self.assertEqual(obj.field, None) self.assertEqual(obj[0], None) - def test_set_value(self): + def test_set_value_silently_drops(self): + """Assignments are dropped without emitting a warning. + + Previously every assignment fired a UserWarning, which + flooded the log with no actionable signal — the cobas_c311 + fixture alone produced ~78 of them per parse. + """ obj = self.Dummy() with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") obj.field = 42 - assert issubclass(w[-1].category, UserWarning) + self.assertEqual(w, []) self.assertEqual(obj.field, None) @@ -122,18 +128,18 @@ def test_get_value(self): def test_set_value(self): obj = self.Dummy() - obj.field = u("привет") - self.assertEqual(obj.field, u("привет")) + obj.field = "привет" + self.assertEqual(obj.field, "привет") def test_set_utf8_value(self): obj = self.Dummy() - obj.field = u("привет").encode("utf-8") - self.assertEqual(obj.field, u("привет")) + obj.field = "привет".encode("utf-8") + self.assertEqual(obj.field, "привет") def test_fail_set_non_utf8_value(self): obj = self.Dummy() try: - obj.field = u("привет").encode("cp1251") + obj.field = "привет".encode("cp1251") except UnicodeDecodeError: pass else: @@ -150,8 +156,8 @@ def test_fail_set_non_string_value(self): def test_raw_value(self): obj = self.Dummy() - obj.field = u("привет") - self.assertEqual(obj._data["field"], u("привет")) + obj.field = "привет" + self.assertEqual(obj._data["field"], "привет") class DateFieldTestCase(ASTMTestBase): @@ -347,13 +353,24 @@ def test_set_value(self): obj.field = "bar" self.assertEqual(obj.field, "bar") - def test_restrict_new_values_by_specified_set(self): + def test_unknown_values_are_accepted_by_default(self): + """A new vocabulary item from a device firmware update + should not crash parsing of every message that contains it. + """ obj = self.Dummy() + obj.field = "boo" + self.assertEqual(obj.field, "boo") + + def test_strict_mode_raises_on_unknown_value(self): + class Dummy(Mapping): + field = fields.SetField( + values=["foo", "bar", "baz"], strict=True) + obj = Dummy() self.assertRaises(ValueError, setattr, obj, "field", "boo") - def test_reject_any_value(self): + def test_strict_with_empty_value_set_rejects_everything(self): class Dummy(Mapping): - field = fields.SetField() + field = fields.SetField(strict=True) obj = Dummy() self.assertRaises(ValueError, setattr, obj, "field", "bar") self.assertRaises(ValueError, setattr, obj, "field", "foo") @@ -377,6 +394,71 @@ def test_raw_value(self): self.assertEqual(obj._data["field"], "foo") +class SetFieldTolerantLoggingTest(ASTMTestBase): + """Tolerant SetField logs the unknown value but does not crash.""" + + def setUp(self): + class Dummy(Mapping): + field = fields.SetField(values=["foo", "bar"]) + self.Dummy = Dummy + + def test_unknown_value_is_logged_at_debug(self): + with self.assertLogs("senaite.astm", level="DEBUG") as log: + obj = self.Dummy() + obj.field = "boo" + self.assertTrue( + any("unexpected value 'boo'" in line for line in log.output), + "expected DEBUG log about unexpected value, got: %r" + "" % log.output) + + +class MultiFormatDateFieldTest(ASTMTestBase): + """Date fields accept extra formats via parse_formats.""" + + def test_date_field_accepts_extra_format(self): + class IsoDateField(fields.DateField): + parse_formats = ("%Y-%m-%d",) + + class Dummy(Mapping): + field = IsoDateField() + + obj = Dummy() + obj.field = "2026-05-09" + self.assertEqual(obj._data["field"], "20260509") + + def test_date_field_canonical_format_still_works(self): + class IsoDateField(fields.DateField): + parse_formats = ("%Y-%m-%d",) + + class Dummy(Mapping): + field = IsoDateField() + + obj = Dummy() + obj.field = "20260509" + self.assertEqual(obj._data["field"], "20260509") + + def test_datetime_field_tries_each_format_in_order(self): + class FlexibleDT(fields.DateTimeField): + parse_formats = ("%Y-%m-%dT%H:%M:%S", "%d/%m/%Y %H:%M:%S") + + class Dummy(Mapping): + field = FlexibleDT() + + obj = Dummy() + obj.field = "09/05/2026 14:30:00" + self.assertEqual(obj._data["field"], "20260509143000") + + def test_date_field_raises_when_no_format_matches(self): + class IsoDateField(fields.DateField): + parse_formats = ("%Y-%m-%d",) + + class Dummy(Mapping): + field = IsoDateField() + + obj = Dummy() + self.assertRaises(ValueError, setattr, obj, "field", "nope") + + class ComponentFieldTestCase(ASTMTestBase): """Test component field """ diff --git a/src/senaite/astm/tests/test_genexpert.py b/src/senaite/astm/tests/test_genexpert.py index 246ea49..6f04623 100644 --- a/src/senaite/astm/tests/test_genexpert.py +++ b/src/senaite/astm/tests/test_genexpert.py @@ -4,7 +4,7 @@ from senaite.astm.constants import ACK from senaite.astm.constants import ENQ from senaite.astm.instruments import genexpert -from senaite.astm.protocol import ASTMProtocol +from senaite.astm.transports.astm.protocol import ASTMProtocol from senaite.astm.tests.base import ASTMTestBase from senaite.astm.wrapper import Wrapper from unittest.mock import MagicMock @@ -28,7 +28,7 @@ async def asyncSetUp(self): # Mock transport and protocol objects self.transport = self.get_mock_transport() self.protocol.transport = self.transport - self.mapping = genexpert.get_mapping() + self.mapping = genexpert.INSTRUMENT.record_map def get_mock_transport(self, ip="127.0.0.1", port=12345): transport = MagicMock() diff --git a/src/senaite/astm/tests/test_hl7_framing.py b/src/senaite/astm/tests/test_hl7_framing.py new file mode 100644 index 0000000..918d3c0 --- /dev/null +++ b/src/senaite/astm/tests/test_hl7_framing.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +"""MLLP framing tests for :mod:`senaite.astm.transports.hl7.framing`. + +The contract under test: + +- :func:`wrap` produces ``SB + payload + EB + CR`` and accepts both + ``bytes`` and ``str`` inputs. +- :func:`extract_messages` consumes complete MLLP blocks out of a + streaming buffer, returns the unconsumed tail for the caller's + next read, and drops bytes that arrive before the first ``SB``. +""" + +import unittest + +from senaite.astm.transports.hl7.framing import ( + EB, CR, MLLP_END, SB, extract_messages, wrap, +) + + +def make_block(payload): + return SB + payload + MLLP_END + + +class WrapTest(unittest.TestCase): + + def test_wrap_bytes(self): + self.assertEqual(wrap(b"hello"), SB + b"hello" + EB + CR) + + def test_wrap_str_uses_utf8(self): + self.assertEqual(wrap("héllo"), SB + "héllo".encode("utf-8") + + EB + CR) + + +class ExtractMessagesTest(unittest.TestCase): + + def test_empty_buffer(self): + messages, remainder = extract_messages(b"") + self.assertEqual(messages, []) + self.assertEqual(remainder, b"") + + def test_one_complete_block(self): + block = make_block(b"MSH|^~\\&|...") + messages, remainder = extract_messages(block) + self.assertEqual(messages, [b"MSH|^~\\&|..."]) + self.assertEqual(remainder, b"") + + def test_two_back_to_back_blocks(self): + buf = make_block(b"FIRST") + make_block(b"SECOND") + messages, remainder = extract_messages(buf) + self.assertEqual(messages, [b"FIRST", b"SECOND"]) + self.assertEqual(remainder, b"") + + def test_partial_block_at_tail_is_returned(self): + complete = make_block(b"DONE") + partial = SB + b"NOT-YET-DONE" + messages, remainder = extract_messages(complete + partial) + self.assertEqual(messages, [b"DONE"]) + self.assertEqual(remainder, partial) + + def test_pre_sb_garbage_is_dropped(self): + garbage = b"random junk before framing" + messages, remainder = extract_messages(garbage) + self.assertEqual(messages, []) + # No SB → caller has nothing useful left. + self.assertEqual(remainder, b"") + + def test_pre_sb_garbage_before_complete_block(self): + buf = b"junk" + make_block(b"REAL") + messages, remainder = extract_messages(buf) + self.assertEqual(messages, [b"REAL"]) + self.assertEqual(remainder, b"") + + def test_streaming_reassembly_in_two_chunks(self): + """Simulate two TCP reads: first half of a block, then the + rest. The first call returns no messages but preserves the + partial frame; the second call yields the complete block.""" + block = make_block(b"STREAMED PAYLOAD") + chunk_one = block[:5] + chunk_two = block[5:] + + messages, buffer = extract_messages(chunk_one) + self.assertEqual(messages, []) + self.assertEqual(buffer, chunk_one) + + buffer += chunk_two + messages, buffer = extract_messages(buffer) + self.assertEqual(messages, [b"STREAMED PAYLOAD"]) + self.assertEqual(buffer, b"") + + def test_eb_without_cr_is_not_a_complete_block(self): + buf = SB + b"PAYLOAD" + EB + messages, remainder = extract_messages(buf) + self.assertEqual(messages, []) + self.assertEqual(remainder, buf) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/senaite/astm/tests/test_hl7_parser.py b/src/senaite/astm/tests/test_hl7_parser.py new file mode 100644 index 0000000..2d7efec --- /dev/null +++ b/src/senaite/astm/tests/test_hl7_parser.py @@ -0,0 +1,221 @@ +# -*- coding: utf-8 -*- +"""Tests for :func:`senaite.astm.transports.hl7.parser.parse`. + +The parser bridges the HL7-over-MLLP transport and the existing +typed :class:`Envelope` so downstream consumers see one schema +regardless of which transport the device speaks. The contract: + +- MSH/PID/OBR/OBX/NTE land in the H/P/O/R/C buckets respectively. +- The raw HL7 text is preserved verbatim in ``metadata.hl7`` so disk + capture and "push the original bytes" flows still work. +- ``metadata.astm`` and ``metadata.lis2a`` stay empty (HL7 envelopes + have no native ASTM representation). +- The number of OBX entries matches the device's spec + (exactly 20 for HemoScreen) and field indices map cleanly to the + HL7 spec numbering. +- Optional NTE segments associated with flagged OBX entries appear + in the C bucket. +- Malformed input raises ``ValueError`` rather than silently + producing an empty envelope. +""" + +import os +import unittest + +from senaite.astm.core.envelope import ( + ENVELOPE_VERSION, Envelope, serialize_envelope, +) +from senaite.astm.transports.hl7.parser import parse + + +HERE = os.path.dirname(__file__) +FIXTURE_DIR = os.path.join(HERE, "data", "hl7") + + +def load_fixture(name): + with open(os.path.join(FIXTURE_DIR, name), "rb") as fh: + return fh.read() + + +class HemoScreenFreshBloodTest(unittest.TestCase): + """Fixture: §8.1.1 fresh-blood ORU^R01.""" + + @classmethod + def setUpClass(cls): + cls.envelope = parse(load_fixture("hemoscreen_fresh_blood.hl7")) + + def test_returns_envelope(self): + self.assertIsInstance(self.envelope, Envelope) + + def test_envelope_version_is_current(self): + self.assertEqual( + self.envelope.metadata.envelope_version, ENVELOPE_VERSION) + + def test_header_bucket_has_sending_application(self): + # MSH-3 == "HemoScreen", MSH-4 == "PixCell" — sender ID. + self.assertEqual(len(self.envelope.H), 1) + msh = self.envelope.H[0] + self.assertEqual(msh["3"], "HemoScreen") + self.assertEqual(msh["4"], "PixCell") + self.assertEqual(msh["9"], "ORU^R01") + self.assertEqual(msh["12"], "2.4") + + def test_patient_bucket_has_test_identifier(self): + # PID-2 carries the HemoScreen Test Identifier (sample ID). + self.assertEqual(len(self.envelope.P), 1) + self.assertEqual(self.envelope.P[0]["2"], "35") + + def test_order_bucket_is_obs(self): + self.assertEqual(len(self.envelope.O), 1) + self.assertEqual(self.envelope.O[0]["4"], "OBS") + + def test_exactly_twenty_obx_results(self): + self.assertEqual(len(self.envelope.R), 20) + + def test_first_obx_is_wbc(self): + obx = self.envelope.R[0] + self.assertEqual(obx["2"], "NM") + self.assertEqual(obx["3"], "WBC") + self.assertEqual(obx["5"], "11.7") + self.assertEqual(obx["6"], "10*3/uL") + self.assertEqual(obx["11"], "F") + # OBX-15 is the device serial. + self.assertEqual(obx["15"], "0000000-0001-HS") + + def test_no_nte_segments_for_unflagged_results(self): + # OBS without flags must not carry NTE entries. + self.assertEqual(len(self.envelope.C), 0) + + def test_metadata_hl7_preserves_raw_message(self): + raw = self.envelope.metadata.hl7 + self.assertTrue(raw.startswith("MSH|^~\\&|HemoScreen|PixCell")) + self.assertIn("OBX|0|NM|WBC", raw) + + def test_metadata_astm_and_lis2a_are_empty(self): + self.assertEqual(self.envelope.metadata.astm, "") + self.assertEqual(self.envelope.metadata.lis2a, "") + + +class HemoScreenQualityControlTest(unittest.TestCase): + """Fixture: §8.1.2 LQC (Liquid Quality Control).""" + + @classmethod + def setUpClass(cls): + cls.envelope = parse( + load_fixture("hemoscreen_quality_control.hl7")) + + def test_observation_type_is_lqc(self): + self.assertEqual(self.envelope.O[0]["4"], "LQC") + + def test_patient_id_is_qc_lot_number(self): + # QC vials carry a lot identifier in PID-2 — not a real + # patient. The HemoScreen integration adapter (PR-8) is + # responsible for routing this away from the LIMS push. + self.assertEqual(self.envelope.P[0]["2"], "PIX240205N") + + def test_reference_ranges_present_for_lqc(self): + # Per spec §5.1.5: reference ranges appear only on LQC. + wbc = self.envelope.R[0] + self.assertEqual(wbc["3"], "WBC") + self.assertEqual(wbc["7"], "5.9-9.3") + + +class HemoScreenProficiencyTest(unittest.TestCase): + """Fixture: §8.1.3 PRF (Proficiency / External Quality Control).""" + + @classmethod + def setUpClass(cls): + cls.envelope = parse( + load_fixture("hemoscreen_proficiency.hl7")) + + def test_observation_type_is_prf(self): + self.assertEqual(self.envelope.O[0]["4"], "PRF") + + def test_no_reference_ranges_for_prf(self): + # Spec §5.1.5: ranges are PRF-suppressed. + for obx in self.envelope.R: + self.assertEqual(obx.get("7", ""), "") + + +class HemoScreenWithFlagsTest(unittest.TestCase): + """Fixture: §8.1.4 flagged fresh-blood ORU^R01. + + Every OBX with a flag is followed by an NTE describing the flag; + the parser must collect those into the C bucket. + """ + + @classmethod + def setUpClass(cls): + cls.envelope = parse(load_fixture("hemoscreen_with_flags.hl7")) + + def test_nte_count_matches_flagged_obx(self): + # The fixture has 10 OBX entries with flags, each followed + # by a matching NTE explaining the flag (NEU# / LYM# / MON# / + # EOS# / BAS# / NEU% / LYM% / MON% / EOS% / BAS%). + self.assertEqual(len(self.envelope.C), 10) + + def test_nte_carries_description(self): + self.assertEqual( + self.envelope.C[0]["3"], + "Abnormal cells may affect marked results") + + def test_obx_with_LL_special_value(self): + # OBX-2 == "ST" plus OBX-5 == "LL" signals below-linear. + # Find the HGB result. + hgb = next(obx for obx in self.envelope.R if obx["3"] == "HGB") + self.assertEqual(hgb["2"], "ST") + self.assertEqual(hgb["5"], "LL") + + def test_obx_with_triple_dash_special_value(self): + mpv = next(obx for obx in self.envelope.R if obx["3"] == "MPV") + self.assertEqual(mpv["5"], "---") + + def test_obx_flag_field_is_populated(self): + # OBX-8 carries the flag symbol when present. + neu = next(obx for obx in self.envelope.R if obx["3"] == "NEU#") + self.assertEqual(neu["8"], "*") + + +class SerializerIntegrationTest(unittest.TestCase): + """``serialize_envelope`` learns the ``"hl7"`` format in PR-7.""" + + def test_serialize_returns_raw_payload(self): + envelope = parse(load_fixture("hemoscreen_fresh_blood.hl7")) + payload = serialize_envelope(envelope, "hl7") + self.assertTrue( + payload.startswith("MSH|^~\\&|HemoScreen|PixCell")) + + def test_serialize_json_includes_hl7_in_metadata(self): + import json + + envelope = parse(load_fixture("hemoscreen_fresh_blood.hl7")) + dumped = json.loads(serialize_envelope(envelope, "json")) + self.assertIn("hl7", dumped["metadata"]) + self.assertEqual(dumped["metadata"]["astm"], "") + self.assertEqual(dumped["metadata"]["lis2a"], "") + + +class ParserRobustnessTest(unittest.TestCase): + + def test_missing_msh_raises(self): + with self.assertRaises(ValueError): + parse(b"random garbage without HL7 structure") + + def test_accepts_bytes_and_str(self): + raw = load_fixture("hemoscreen_fresh_blood.hl7") + from_bytes = parse(raw) + from_str = parse(raw.decode("utf-8")) + # Bucket counts should match. + self.assertEqual(len(from_bytes.R), len(from_str.R)) + + def test_normalises_lf_to_cr(self): + # Replace inter-segment terminators with \n; parser must + # still produce a complete envelope. + raw = load_fixture("hemoscreen_fresh_blood.hl7") + lf_version = raw.replace(b"\r\n", b"\n").replace(b"\r", b"\n") + envelope = parse(lf_version) + self.assertEqual(len(envelope.R), 20) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/senaite/astm/tests/test_hl7_protocol.py b/src/senaite/astm/tests/test_hl7_protocol.py new file mode 100644 index 0000000..e1a6bde --- /dev/null +++ b/src/senaite/astm/tests/test_hl7_protocol.py @@ -0,0 +1,203 @@ +# -*- coding: utf-8 -*- +"""End-to-end tests for the HL7 transport. + +Boots the real listener on a local port, replays each bundled +HemoScreen HL7 fixture against it, and asserts: + +- the listener writes the captured payload to ``--output``; +- the listener responds with a comm-level ACK^R01 wrapped in MLLP; +- the ACK echoes the inbound Message Control ID (MSH-10); +- the ACK uses MSA|AA when the input parses. +""" + +import asyncio +import os +import tempfile +import unittest + +from senaite.astm.cli import hl7_server +from senaite.astm.cli._runtime import drain_tasks +from senaite.astm.cli._runtime import make_tracked_dispatcher +from senaite.astm.core.pipeline import Pipeline +from senaite.astm.transports.hl7.framing import extract_messages, wrap +from senaite.astm.transports.hl7.protocol import HL7Protocol +from senaite.astm.transports.hl7.protocol import build_ack + + +HERE = os.path.dirname(__file__) +FIXTURE_DIR = os.path.join(HERE, "data", "hl7") + + +def load_fixture(name): + with open(os.path.join(FIXTURE_DIR, name), "rb") as fh: + # Normalise newlines to HL7 segment terminators. + return fh.read().replace(b"\r\n", b"\r").replace(b"\n", b"\r") \ + .rstrip(b"\r") + + +class BuildAckTest(unittest.TestCase): + + def test_ack_echoes_message_control_id(self): + fixture = load_fixture("hemoscreen_fresh_blood.hl7") + ack = build_ack(fixture) + # MSA|AA| where the control id from the fixture + # is "0" (MSH-10). + self.assertIn(b"MSA|AA|0", ack) + + def test_ack_carries_encoding_characters(self): + fixture = load_fixture("hemoscreen_quality_control.hl7") + ack = build_ack(fixture) + self.assertIn(b"|^~\\&|", ack) + + def test_ack_message_type_is_ack_r01(self): + fixture = load_fixture("hemoscreen_proficiency.hl7") + ack = build_ack(fixture) + self.assertIn(b"ACK^R01", ack) + + def test_ack_falls_back_for_unparseable_msh(self): + # build_ack should still produce a usable ACK even when the + # MSH is missing. + ack = build_ack(b"GARBAGE") + self.assertIn(b"MSH|", ack) + self.assertIn(b"MSA|AA|", ack) + + +class HL7ServerTest(unittest.IsolatedAsyncioTestCase): + + PORT = 7985 + + async def asyncSetUp(self): + self.tmpdir = tempfile.mkdtemp() + self.addCleanup(self._cleanup_tmpdir) + + self.task_set = set() + self.received = [] + + async def capture(payload): + self.received.append(payload) + + self.pipeline = Pipeline([capture]) + self.loop = asyncio.get_event_loop() + dispatch = make_tracked_dispatcher( + self.loop, self.pipeline, self.task_set) + self.server = await self.loop.create_server( + lambda: HL7Protocol(frame_callback=dispatch), + host="127.0.0.1", port=self.PORT) + + async def asyncTearDown(self): + self.server.close() + await self.server.wait_closed() + await drain_tasks(self.task_set, grace_seconds=2) + + def _cleanup_tmpdir(self): + import shutil + shutil.rmtree(self.tmpdir, ignore_errors=True) + + async def _send_and_recv(self, payload): + reader, writer = await asyncio.open_connection("127.0.0.1", + self.PORT) + try: + writer.write(wrap(payload)) + await writer.drain() + + buffer = b"" + for _ in range(10): + chunk = await asyncio.wait_for(reader.read(1024), + timeout=2.0) + if not chunk: + break + buffer += chunk + messages, buffer = extract_messages(buffer) + if messages: + return messages[0] + raise AssertionError("Server did not send an ACK") + finally: + writer.close() + await writer.wait_closed() + + async def test_fresh_blood_is_dispatched_and_acked(self): + payload = load_fixture("hemoscreen_fresh_blood.hl7") + ack = await self._send_and_recv(payload) + + # Pipeline received the payload exactly once. + await drain_tasks(self.task_set, grace_seconds=2) + self.assertEqual(self.received, [payload]) + + # ACK echoes the control ID and uses AA. + self.assertIn(b"MSA|AA|0", ack) + self.assertIn(b"ACK^R01", ack) + + async def test_quality_control_is_dispatched(self): + payload = load_fixture("hemoscreen_quality_control.hl7") + ack = await self._send_and_recv(payload) + await drain_tasks(self.task_set, grace_seconds=2) + self.assertEqual(self.received, [payload]) + # Control ID is "1" for the QC fixture. + self.assertIn(b"MSA|AA|1", ack) + + async def test_two_messages_one_connection(self): + """Verifies the protocol can drain multiple MLLP blocks from + a single TCP socket.""" + first = load_fixture("hemoscreen_fresh_blood.hl7") + second = load_fixture("hemoscreen_proficiency.hl7") + + reader, writer = await asyncio.open_connection("127.0.0.1", + self.PORT) + try: + writer.write(wrap(first) + wrap(second)) + await writer.drain() + + collected = [] + buffer = b"" + while len(collected) < 2: + chunk = await asyncio.wait_for(reader.read(1024), + timeout=2.0) + if not chunk: + break + buffer += chunk + messages, buffer = extract_messages(buffer) + collected.extend(messages) + finally: + writer.close() + await writer.wait_closed() + + await drain_tasks(self.task_set, grace_seconds=2) + + self.assertEqual(len(collected), 2) + self.assertEqual(self.received, [first, second]) + + +class CLIBuildPipelineTest(unittest.TestCase): + """``cli.hl7_server.build_pipeline`` now consumes parsed envelopes. + + Without ``--url`` (no session) the pipeline only carries a disk + capture handler. The implicit ``$CWD/astm_messages`` magic was + already gone in PR-H and stays gone here too. + """ + + def _args(self, **overrides): + defaults = dict( + output=None, + retries=1, + delay=0, + consumer="x", + message_format="json", + ) + defaults.update(overrides) + return type("_Args", (object,), defaults)() + + def test_no_output_no_session_means_empty_pipeline(self): + pipeline = hl7_server.build_pipeline(self._args(), session=None) + self.assertEqual(len(pipeline), 0) + + def test_with_output_adds_disk_capture(self): + with tempfile.TemporaryDirectory() as tmp: + pipeline = hl7_server.build_pipeline( + self._args(output=tmp), session=None) + self.assertEqual(len(pipeline), 1) + self.assertEqual(pipeline.handlers[0].name, "disk_capture") + self.assertEqual(pipeline.handlers[0].ext, ".hl7") + + +if __name__ == "__main__": + unittest.main() diff --git a/src/senaite/astm/tests/test_instrument_registry.py b/src/senaite/astm/tests/test_instrument_registry.py new file mode 100644 index 0000000..3acd034 --- /dev/null +++ b/src/senaite/astm/tests/test_instrument_registry.py @@ -0,0 +1,175 @@ +# -*- coding: utf-8 -*- + +import re +import unittest + +from senaite.astm import records +from senaite.astm.core.instrument import AmbiguousInstrumentError +from senaite.astm.core.instrument import Instrument +from senaite.astm.core.instrument import find_instrument +from senaite.astm.core.instrument import register_instrument +from senaite.astm.core.instrument import registered_instruments +from senaite.astm.core.instrument import unregister_instrument + + +def _make_record_map(): + return { + "H": records.HeaderRecord, + "P": records.PatientRecord, + "O": records.OrderRecord, + "R": records.ResultRecord, + "C": records.CommentRecord, + "L": records.TerminatorRecord, + } + + +class InstrumentRegistryTest(unittest.TestCase): + + def setUp(self): + self._registered = [] + + def tearDown(self): + for name in self._registered: + unregister_instrument(name) + + def _register(self, cls): + self._registered.append(cls.name) + return register_instrument(cls) + + def test_registers_and_resolves_by_header(self): + @self._register + class FakeAlpha(Instrument): + name = "test:alpha" + header_regex = re.compile(rb".*FakeAlpha\^") + record_map = _make_record_map() + + header = b"1H|\\^&|||FakeAlpha^v1|||||EPR||P|1|20260512|" + instrument = find_instrument(header) + self.assertIsNotNone(instrument) + self.assertEqual(instrument.name, "test:alpha") + self.assertIn(instrument, registered_instruments()) + + def test_unknown_header_returns_none(self): + @self._register + class FakeBeta(Instrument): + name = "test:beta" + header_regex = re.compile(rb".*FakeBeta\^") + record_map = _make_record_map() + + self.assertIsNone(find_instrument(b"1H|\\^&|||Other^|||")) + + def test_overlapping_regexes_raise(self): + @self._register + class FakeOne(Instrument): + name = "test:one" + header_regex = re.compile(rb".*Shared\^") + record_map = _make_record_map() + + @self._register + class FakeTwo(Instrument): + name = "test:two" + header_regex = re.compile(rb".*Shared\^") + record_map = _make_record_map() + + with self.assertRaises(AmbiguousInstrumentError): + find_instrument(b"1H|\\^&|||Shared^v|||") + + def test_handle_raw_data_defaults_to_none(self): + @self._register + class FakeGamma(Instrument): + name = "test:gamma" + header_regex = re.compile(rb".*Gamma\^") + record_map = _make_record_map() + + self.assertIsNone(FakeGamma().handle_raw_data(None, b"any")) + + def test_can_handle_raw_uses_raw_data_regex(self): + from senaite.astm.core.instrument import find_raw_data_handler + + @self._register + class FakeDelta(Instrument): + name = "test:delta" + header_regex = re.compile(rb".*Delta\^") + raw_data_regex = re.compile(rb"^\x02DELTA") + record_map = _make_record_map() + + def handle_raw_data(self, protocol, data): + return b"OK" + + self.assertIsNotNone(find_raw_data_handler(b"\x02DELTA-payload")) + self.assertIsNone(find_raw_data_handler(b"unrelated")) + + def test_metadata_defaults_to_empty(self): + @self._register + class FakeEpsilon(Instrument): + name = "test:epsilon" + header_regex = re.compile(rb".*Epsilon\^") + record_map = _make_record_map() + + self.assertEqual(FakeEpsilon().get_metadata(wrapper=None), {}) + + def test_registration_validates_required_attributes(self): + class Missing(Instrument): + pass + + with self.assertRaises(ValueError): + register_instrument(Missing) + + class MissingRegex(Instrument): + name = "test:no-regex" + record_map = _make_record_map() + + with self.assertRaises(ValueError): + register_instrument(MissingRegex) + + class MissingMap(Instrument): + name = "test:no-map" + header_regex = re.compile(rb".*x") + + with self.assertRaises(ValueError): + register_instrument(MissingMap) + + def test_non_instrument_subclass_rejected(self): + class NotAnInstrument(object): + name = "test:bogus" + + with self.assertRaises(TypeError): + register_instrument(NotAnInstrument) + + +class WrapperRegistryIntegrationTest(unittest.TestCase): + """Wrapper resolves the mapping entirely through the registry.""" + + def setUp(self): + self._registered = [] + + def tearDown(self): + for name in self._registered: + unregister_instrument(name) + + def _register(self, cls): + self._registered.append(cls.name) + return register_instrument(cls) + + def test_wrapper_uses_registered_instrument(self): + from senaite.astm.wrapper import Wrapper + + custom_map = {"H": records.HeaderRecord} + + @self._register + class FakeMega(Instrument): + name = "test:mega" + header_regex = re.compile(rb".*MegaProbe\^") + record_map = custom_map + + wrapper = Wrapper([b"1H|\\^&|||MegaProbe^|||"]) + self.assertIs(wrapper.instrument.__class__, FakeMega) + self.assertEqual(set(wrapper.mapping), {"H"}) + + def test_unknown_header_falls_back_to_default_mapping(self): + from senaite.astm.wrapper import DEFAULT_MAPPING + from senaite.astm.wrapper import Wrapper + + wrapper = Wrapper([b"1H|\\^&|||TotallyUnknown^|||"]) + self.assertIsNone(wrapper.instrument) + self.assertEqual(wrapper.mapping, DEFAULT_MAPPING) diff --git a/src/senaite/astm/tests/test_lims.py b/src/senaite/astm/tests/test_lims.py index 1ad56cb..0003daf 100644 --- a/src/senaite/astm/tests/test_lims.py +++ b/src/senaite/astm/tests/test_lims.py @@ -6,9 +6,12 @@ import responses -from senaite.astm import lims -from senaite.astm.lims import Session -from senaite.astm.lims import post_to_senaite +from senaite.astm.core.lims import PushResult +from senaite.astm.core.lims import SenaiteAuthError +from senaite.astm.core.lims import SenaiteHTTPError +from senaite.astm.core.lims import SenaiteUnreachableError +from senaite.astm.core.lims import Session +from senaite.astm.core.lims import post_to_senaite URL = "http://admin:secret@senaite.example.com" BASE = "http://senaite.example.com/@@API/senaite/v1" @@ -49,7 +52,7 @@ def auth_bad_credentials(): class SessionAuthTest(unittest.TestCase): - """Session.auth() exercises both the version probe and the user probe. + """Session.auth() exercises the version probe and the user probe. """ def test_init_extracts_credentials_from_url(self): @@ -64,49 +67,90 @@ def test_get_url_joins_endpoint(self): session.get_url("push"), "http://senaite.example.com/@@API/senaite/v1/push") + def test_session_is_cached_across_calls(self): + """The TLS handshake is amortised by reusing one + requests.Session across all calls. + """ + session = Session(URL) + self.assertIs(session.session, session.session) + @responses.activate def test_auth_happy_path(self): auth_ok() self.assertTrue(Session(URL).auth()) @responses.activate - def test_auth_returns_false_when_jsonapi_missing(self): + def test_auth_raises_when_jsonapi_missing(self): auth_no_jsonapi() - self.assertFalse(Session(URL).auth()) + with self.assertRaises(SenaiteAuthError): + Session(URL).auth() @responses.activate - def test_auth_returns_false_when_credentials_invalid(self): + def test_auth_raises_when_credentials_invalid(self): auth_bad_credentials() - self.assertFalse(Session(URL).auth()) + with self.assertRaises(SenaiteAuthError): + Session(URL).auth() @responses.activate - def test_get_returns_empty_dict_on_non_200(self): + def test_get_raises_http_error_on_non_200(self): responses.add( responses.GET, "{}/anything".format(BASE), json={"error": "boom"}, status=500) - self.assertEqual(Session(URL).get("anything"), {}) + with self.assertRaises(SenaiteHTTPError) as ctx: + Session(URL).get("anything") + self.assertEqual(ctx.exception.status_code, 500) + + @responses.activate + def test_get_raises_unreachable_on_connection_error(self): + # No matching response registered -> requests raises + with self.assertRaises(SenaiteUnreachableError): + Session(URL).get("anything") @responses.activate - def test_get_returns_empty_dict_on_connection_error(self): - # No matching response registered → ConnectionError - self.assertEqual(Session(URL).get("anything"), {}) + def test_post_raises_unreachable_on_connection_error(self): + with self.assertRaises(SenaiteUnreachableError): + Session(URL).post("push", {"x": 1}) @responses.activate - def test_post_returns_empty_dict_on_connection_error(self): - self.assertEqual(Session(URL).post("push", {"x": 1}), {}) + def test_post_raises_http_error_on_non_200(self): + responses.add( + responses.POST, + "{}/push".format(BASE), + json={"error": "boom"}, + status=503) + with self.assertRaises(SenaiteHTTPError) as ctx: + Session(URL).post("push", {"x": 1}) + self.assertEqual(ctx.exception.status_code, 503) + + +class PushResultTest(unittest.TestCase): + """PushResult is the documented return type of post_to_senaite.""" + + def test_default_last_error_is_none(self): + result = PushResult(success=True, attempts=1) + self.assertTrue(result.success) + self.assertEqual(result.attempts, 1) + self.assertIsNone(result.last_error) + + def test_carries_last_error(self): + err = SenaiteHTTPError("boom", status_code=500) + result = PushResult(success=False, attempts=3, last_error=err) + self.assertFalse(result.success) + self.assertEqual(result.attempts, 3) + self.assertIs(result.last_error, err) class PostToSenaiteTest(unittest.TestCase): - """post_to_senaite handles auth, push, and the retry/delay loop. + """post_to_senaite authenticates once, then retries POST only. - The loop reads `time.sleep` from `senaite.astm.lims`, so we patch - that import to keep the tests fast. + The loop reads `time.sleep` from `senaite.astm.core.lims`, so we + patch that import to keep the tests fast. """ def setUp(self): - sleep_patcher = patch("senaite.astm.lims.sleep") + sleep_patcher = patch("senaite.astm.core.lims.sleep") self.sleep = sleep_patcher.start() self.addCleanup(sleep_patcher.stop) @@ -118,16 +162,17 @@ def test_happy_path_no_retry(self): "{}/push".format(BASE), json={"success": True}, status=200) - post_to_senaite([b"msg"], Session(URL)) - # auth (2 GETs) + 1 POST = 3 calls + result = post_to_senaite([b"msg"], Session(URL)) + # 1 auth pair (2 GETs) + 1 POST = 3 calls self.assertEqual(len(responses.calls), 3) self.sleep.assert_not_called() + self.assertTrue(result.success) + self.assertEqual(result.attempts, 1) + self.assertIsNone(result.last_error) @responses.activate - def test_retry_then_success(self): - # auth happens once per attempt; register enough responses for two - for _ in range(2): - auth_ok() + def test_retry_then_success_authenticates_only_once(self): + auth_ok() responses.add( responses.POST, "{}/push".format(BASE), @@ -138,35 +183,58 @@ def test_retry_then_success(self): "{}/push".format(BASE), json={"success": True}, status=200) - post_to_senaite([b"msg"], Session(URL), retries=3, delay=1) - # 2 auth pairs + 2 POSTs = 6 calls - self.assertEqual(len(responses.calls), 6) - # one sleep between the two attempts + result = post_to_senaite( + [b"msg"], Session(URL), retries=3, delay=1) + # 1 auth pair + 2 POSTs = 4 calls (no second auth) + self.assertEqual(len(responses.calls), 4) self.sleep.assert_called_once_with(1) + self.assertTrue(result.success) + self.assertEqual(result.attempts, 2) @responses.activate def test_retry_exhausted(self): - for _ in range(3): - auth_ok() + auth_ok() for _ in range(3): responses.add( responses.POST, "{}/push".format(BASE), json={"success": False}, status=200) - post_to_senaite([b"msg"], Session(URL), retries=3, delay=1) - # 3 auth pairs + 3 POSTs = 9 calls - self.assertEqual(len(responses.calls), 9) + result = post_to_senaite( + [b"msg"], Session(URL), retries=3, delay=1) + # 1 auth pair + 3 POSTs = 5 calls (auth not re-run) + self.assertEqual(len(responses.calls), 5) # two sleeps: between attempts 1-2 and 2-3, none after the last self.assertEqual(self.sleep.call_count, 2) + self.assertFalse(result.success) + self.assertEqual(result.attempts, 3) + self.assertIsInstance(result.last_error, SenaiteHTTPError) - def test_auth_failure_still_retries(self): - """If auth fails the loop should still respect the retry budget.""" + def test_retry_recovers_from_connection_error(self): + """Connection-level failures are retried, not propagated.""" session = MagicMock() - session.auth.return_value = False - post_to_senaite([b"msg"], session, retries=3, delay=0) - self.assertEqual(session.auth.call_count, 3) + session.auth.return_value = True + session.post.side_effect = [ + SenaiteUnreachableError("network blip"), + {"success": True}, + ] + result = post_to_senaite( + [b"msg"], session, retries=3, delay=0) + self.assertTrue(result.success) + self.assertEqual(result.attempts, 2) + self.assertEqual(session.post.call_count, 2) + + def test_auth_failure_skips_post_and_does_not_retry(self): + """If auth fails the loop bails out without firing POSTs.""" + session = MagicMock() + session.auth.side_effect = SenaiteAuthError("nope") + result = post_to_senaite( + [b"msg"], session, retries=3, delay=0) + session.auth.assert_called_once() session.post.assert_not_called() + self.assertFalse(result.success) + self.assertEqual(result.attempts, 0) + self.assertIsInstance(result.last_error, SenaiteAuthError) def test_consumer_arg_passed_through(self): """`consumer` propagates from kwargs into the POST payload.""" @@ -186,18 +254,3 @@ def test_default_consumer(self): post_to_senaite([b"msg"], session) _, payload = session.post.call_args[0] self.assertEqual(payload["consumer"], "senaite.lis2a.import") - - -class LimsModuleSurfaceTest(unittest.TestCase): - """Lock down the public surface of the module so refactors that - rename or move these symbols break this test loudly. - """ - - def test_post_to_senaite_is_exported(self): - self.assertTrue(callable(lims.post_to_senaite)) - - def test_session_is_exported(self): - self.assertTrue(callable(lims.Session)) - - def test_api_base_url_constant(self): - self.assertEqual(lims.API_BASE_URL, "@@API/senaite/v1") diff --git a/src/senaite/astm/tests/test_mini_vidas.py b/src/senaite/astm/tests/test_mini_vidas.py index 4b810c8..e915bae 100644 --- a/src/senaite/astm/tests/test_mini_vidas.py +++ b/src/senaite/astm/tests/test_mini_vidas.py @@ -5,7 +5,7 @@ from senaite.astm import codec from senaite.astm.constants import ACK, ENQ -from senaite.astm.protocol import ASTMProtocol +from senaite.astm.transports.astm.protocol import ASTMProtocol from senaite.astm.tests.base import ASTMTestBase from senaite.astm.wrapper import Wrapper from senaite.astm.instruments import biomerieux_mini_vidas @@ -29,7 +29,7 @@ async def asyncSetUp(self): # Mock transport and protocol objects self.transport = self.get_mock_transport() self.protocol.transport = self.transport - self.mapping = biomerieux_mini_vidas.get_mapping() + self.mapping = biomerieux_mini_vidas.INSTRUMENT.record_map def get_mock_transport(self, ip="127.0.0.1", port=12345): transport = MagicMock() diff --git a/src/senaite/astm/tests/test_output.py b/src/senaite/astm/tests/test_output.py new file mode 100644 index 0000000..842aa57 --- /dev/null +++ b/src/senaite/astm/tests/test_output.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +"""Tests for :class:`senaite.astm.core.output.DiskCaptureHandler`. + +PR-H promoted disk capture from an implicit ``protocol.log_message`` +side effect (rooted at ``$CWD/astm_messages``) to a first-class +pipeline handler. The contract under test: + +- ``path=None`` / empty path makes the handler a no-op (used by the + CLI when ``--output`` is not supplied). +- A normal call writes one file containing the raw ASTM payload. +- The target directory is created on first write if it does not + already exist. +""" + +import asyncio +import os +import tempfile +import unittest + +from senaite.astm.core.envelope import Envelope, Metadata +from senaite.astm.core.output import DiskCaptureHandler + + +def make_envelope(astm="raw-astm-bytes"): + return Envelope(metadata=Metadata(astm=astm, lis2a="lis")) + + +class DiskCaptureHandlerTest(unittest.IsolatedAsyncioTestCase): + + async def test_noop_when_path_is_none(self): + handler = DiskCaptureHandler(path=None) + # Must not raise and must not create any file anywhere. + await handler(make_envelope()) + + async def test_noop_when_path_is_empty_string(self): + handler = DiskCaptureHandler(path="") + await handler(make_envelope()) + + async def test_writes_one_file_per_envelope(self): + with tempfile.TemporaryDirectory() as tmp: + handler = DiskCaptureHandler(path=tmp) + await handler(make_envelope("session-one")) + # The timestamp-derived filename has 1-second resolution, + # so back-to-back writes can collide and overwrite. Wait + # past the boundary before the second write. + await asyncio.sleep(1.05) + await handler(make_envelope("session-two")) + + files = sorted(os.listdir(tmp)) + self.assertEqual(len(files), 2) + + # Files contain the raw ASTM payload, not the JSON envelope. + contents = [] + for name in files: + with open(os.path.join(tmp, name), "rb") as fh: + contents.append(fh.read()) + self.assertIn(b"session-one", contents[0] + contents[1]) + self.assertIn(b"session-two", contents[0] + contents[1]) + + async def test_creates_target_directory_if_missing(self): + with tempfile.TemporaryDirectory() as tmp: + target = os.path.join(tmp, "does", "not", "exist") + self.assertFalse(os.path.exists(target)) + + handler = DiskCaptureHandler(path=target) + await handler(make_envelope()) + + self.assertTrue(os.path.isdir(target)) + self.assertEqual(len(os.listdir(target)), 1) + + async def test_handler_exposes_name_for_pipeline_logging(self): + # The pipeline uses ``handler.name`` in its error reports. + self.assertEqual(DiskCaptureHandler(path="/tmp").name, "disk_capture") + + +class CLIBuildPipelineTest(unittest.TestCase): + """``cli.astm_server.build_pipeline`` no longer auto-discovers + capture targets. The implicit ``$CWD/astm_messages/`` magic is + gone.""" + + def test_no_output_means_no_capture_handler(self): + from senaite.astm.cli import astm_server + + class _Args(object): + output = None + retries = 1 + delay = 0 + consumer = "x" + message_format = "json" + + # Even if the legacy magic directory existed in CWD, no + # capture handler must be added when --output is absent. + with tempfile.TemporaryDirectory() as tmp: + os.makedirs(os.path.join(tmp, "astm_messages")) + cwd = os.getcwd() + try: + os.chdir(tmp) + pipeline = astm_server.build_pipeline(_Args(), session=None) + finally: + os.chdir(cwd) + self.assertEqual(len(pipeline), 0) + + def test_explicit_output_adds_capture_handler(self): + from senaite.astm.cli import astm_server + + with tempfile.TemporaryDirectory() as tmp: + class _Args(object): + output = tmp + retries = 1 + delay = 0 + consumer = "x" + message_format = "json" + + pipeline = astm_server.build_pipeline(_Args(), session=None) + self.assertEqual(len(pipeline), 1) + self.assertEqual(pipeline.handlers[0].name, "disk_capture") + + +if __name__ == "__main__": + unittest.main() diff --git a/src/senaite/astm/tests/test_pentra_xlr.py b/src/senaite/astm/tests/test_pentra_xlr.py index 736f4cd..ccfa106 100644 --- a/src/senaite/astm/tests/test_pentra_xlr.py +++ b/src/senaite/astm/tests/test_pentra_xlr.py @@ -5,7 +5,7 @@ from senaite.astm import codec from senaite.astm.constants import ACK, ENQ -from senaite.astm.protocol import ASTMProtocol +from senaite.astm.transports.astm.protocol import ASTMProtocol from senaite.astm.tests.base import ASTMTestBase from senaite.astm.wrapper import Wrapper from senaite.astm.instruments import horiba_pentra_xlr @@ -25,7 +25,7 @@ async def asyncSetUp(self): # Mock transport and protocol objects self.transport = self.get_mock_transport() self.protocol.transport = self.transport - self.mapping = horiba_pentra_xlr.get_mapping() + self.mapping = horiba_pentra_xlr.INSTRUMENT.record_map def get_mock_transport(self, ip="127.0.0.1", port=12345): transport = MagicMock() diff --git a/src/senaite/astm/tests/test_pipeline.py b/src/senaite/astm/tests/test_pipeline.py new file mode 100644 index 0000000..e5a534e --- /dev/null +++ b/src/senaite/astm/tests/test_pipeline.py @@ -0,0 +1,158 @@ +# -*- coding: utf-8 -*- +"""Tests for :class:`senaite.astm.core.pipeline.Pipeline`. + +The pipeline is the seam between the transport (which produces +:class:`Envelope` objects) and the outputs (disk capture, LIMS push, +future archivers). The contract under test: + +- handlers fire in registration order +- a sync handler is awaited like an async one +- an exception in handler N is caught, recorded, and does not skip + handler N+1 +- the recorded results name handlers via their ``name`` attribute + when one is set, falling back to ``__name__`` / ``__class__`` +""" + +import unittest + +from senaite.astm.core.pipeline import Pipeline + + +class PipelineTest(unittest.IsolatedAsyncioTestCase): + + async def test_handlers_run_in_order(self): + order = [] + + async def first(env): + order.append("first") + + async def second(env): + order.append("second") + + async def third(env): + order.append("third") + + pipeline = Pipeline([first, second, third]) + await pipeline.run(envelope=object()) + + self.assertEqual(order, ["first", "second", "third"]) + + async def test_sync_handler_is_awaited(self): + seen = [] + + def sync_handler(env): + seen.append(env) + + pipeline = Pipeline([sync_handler]) + await pipeline.run("payload") + + self.assertEqual(seen, ["payload"]) + + async def test_exception_in_handler_does_not_skip_next(self): + order = [] + + async def first(env): + order.append("first") + + async def boom(env): + order.append("boom-entered") + raise RuntimeError("expected") + + async def third(env): + order.append("third") + + pipeline = Pipeline([first, boom, third]) + results = await pipeline.run(object()) + + self.assertEqual(order, ["first", "boom-entered", "third"]) + + names = [name for name, _ in results] + self.assertEqual(names[0], "first") + self.assertEqual(names[2], "third") + + errors = [exc for _, exc in results] + self.assertIsNone(errors[0]) + self.assertIsInstance(errors[1], RuntimeError) + self.assertIsNone(errors[2]) + + async def test_handler_name_attribute_wins(self): + class NamedHandler(object): + name = "my_handler" + + async def __call__(self, env): + pass + + pipeline = Pipeline([NamedHandler()]) + results = await pipeline.run(object()) + self.assertEqual(results[0][0], "my_handler") + + async def test_handler_function_name_fallback(self): + async def disk_capture(env): + pass + + pipeline = Pipeline([disk_capture]) + results = await pipeline.run(object()) + self.assertEqual(results[0][0], "disk_capture") + + async def test_empty_pipeline_is_a_noop(self): + pipeline = Pipeline() + results = await pipeline.run(object()) + self.assertEqual(results, []) + self.assertEqual(len(pipeline), 0) + + async def test_add_appends_handler(self): + pipeline = Pipeline() + + async def h(env): + pass + + pipeline.add(h) + self.assertEqual(len(pipeline), 1) + + +class HandlersTest(unittest.IsolatedAsyncioTestCase): + + def test_serialize_envelope_json(self): + from senaite.astm.core.envelope import Envelope, Metadata + from senaite.astm.core.envelope import serialize_envelope + + envelope = Envelope(metadata=Metadata(astm="A", lis2a="L")) + payload = serialize_envelope(envelope, "json") + self.assertIn("\"astm\":\"A\"", payload) + self.assertIn("\"envelope_version\"", payload) + + def test_serialize_envelope_astm_uses_metadata(self): + from senaite.astm.core.envelope import Envelope, Metadata + from senaite.astm.core.envelope import serialize_envelope + + envelope = Envelope(metadata=Metadata(astm="raw-astm", lis2a="L")) + self.assertEqual( + serialize_envelope(envelope, "astm"), "raw-astm") + + def test_serialize_envelope_lis2a_uses_metadata(self): + from senaite.astm.core.envelope import Envelope, Metadata + from senaite.astm.core.envelope import serialize_envelope + + envelope = Envelope(metadata=Metadata(astm="A", lis2a="raw-lis2a")) + self.assertEqual( + serialize_envelope(envelope, "lis2a"), "raw-lis2a") + + def test_serialize_envelope_unknown_format_raises(self): + from senaite.astm.core.envelope import Envelope, Metadata + from senaite.astm.core.envelope import serialize_envelope + + envelope = Envelope(metadata=Metadata(astm="A", lis2a="L")) + with self.assertRaises(ValueError): + serialize_envelope(envelope, "xml") + + async def test_disk_capture_noop_without_path(self): + from senaite.astm.core.envelope import Envelope, Metadata + from senaite.astm.core.output import DiskCaptureHandler + + handler = DiskCaptureHandler(path=None) + envelope = Envelope(metadata=Metadata(astm="A", lis2a="L")) + await handler(envelope) # must not raise + + +if __name__ == "__main__": + unittest.main() diff --git a/src/senaite/astm/tests/test_replay_corpus.py b/src/senaite/astm/tests/test_replay_corpus.py new file mode 100644 index 0000000..ca9ebec --- /dev/null +++ b/src/senaite/astm/tests/test_replay_corpus.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +"""Replay real-traffic ASTM captures through :class:`Wrapper`. + +The corpus under :envvar:`ASTM_REPLAY_DIR` is operator-supplied +(typically rsynced off a production capture directory) and contains +whatever bytes the upstream device emitted — including malformed, +truncated, and empty frames captured by accident. The point of this +test is regression detection, not perfect parsing: we want to know +when a refactor pushes the failure rate above its historical baseline. + +The test passes when: + +* less than :data:`MAX_FAILURE_RATIO` of files raise on parse, +* every successful parse declares the current envelope version. + +When :envvar:`ASTM_REPLAY_DIR` is unset (CI) the test skips. +""" + +import os +import unittest + +from senaite.astm.core.envelope import ENVELOPE_VERSION +from senaite.astm.wrapper import Wrapper + +REPLAY_DIR = os.environ.get("ASTM_REPLAY_DIR") + +# Empirical baseline against a ~50k-file production corpus: ~3.1% fail +# (mostly truncated Roche c111 sessions where the recorder flushed +# before the trailing , plus a handful of malformed payloads). +# Anything materially above this threshold means a refactor regressed +# parsing for real traffic. +MAX_FAILURE_RATIO = 0.05 + + +def _envelope_for(path): + with open(path, "rb") as f: + lines = [line.rstrip(b"\n") for line in f.readlines()] + lines = [line for line in lines if line] + return Wrapper(lines).to_envelope() + + +def _collect_files(root): + paths = [] + for parent, _, names in os.walk(root): + for name in names: + if name.startswith("."): + continue + paths.append(os.path.join(parent, name)) + return sorted(paths) + + +@unittest.skipUnless( + REPLAY_DIR and os.path.isdir(REPLAY_DIR), + "ASTM_REPLAY_DIR not set or directory missing") +class ReplayCorpusTest(unittest.TestCase): + + def test_corpus_parses_within_tolerance(self): + files = _collect_files(REPLAY_DIR) + self.assertTrue( + files, "%s contains no replay files" % REPLAY_DIR) + + failures = [] + version_mismatch = [] + for path in files: + try: + envelope = _envelope_for(path) + except Exception as exc: + failures.append((path, type(exc).__name__, str(exc))) + continue + if envelope.metadata.envelope_version != ENVELOPE_VERSION: + version_mismatch.append( + (path, envelope.metadata.envelope_version)) + + ratio = len(failures) / len(files) + self.assertLess( + ratio, MAX_FAILURE_RATIO, + "replay failure rate %.4f exceeds tolerance %.4f " + "(%d/%d files); sample failures:\n%s" % ( + ratio, MAX_FAILURE_RATIO, + len(failures), len(files), + "\n".join("%s: %s — %s" % f for f in failures[:5]))) + + self.assertEqual( + version_mismatch, [], + "envelopes carried a stale envelope_version: %r" + % version_mismatch[:5]) diff --git a/src/senaite/astm/tests/test_server_lifecycle.py b/src/senaite/astm/tests/test_server_lifecycle.py new file mode 100644 index 0000000..d7b2ac3 --- /dev/null +++ b/src/senaite/astm/tests/test_server_lifecycle.py @@ -0,0 +1,240 @@ +# -*- coding: utf-8 -*- +"""Tests for the hardened CLI server lifecycle. + +PR-G replaced the legacy ``server.main`` with an async ``amain`` +that: + +- writes its logfile via a sane :class:`RotatingFileHandler` (10 MB + per file, 5 backups) instead of rotating after every record; +- tracks every pipeline-run task it dispatches so shutdown can + ``await`` in-flight work; +- waits up to ``--shutdown-grace-seconds`` for those tasks before + cancelling them. + +The tests below cover those guarantees without orchestrating a real +SIGTERM (which is fiddly under ``pytest``): they exercise +``_drain_tasks``, ``make_frame_callback`` and the logging setup +directly. +""" + +import asyncio +import logging +import logging.handlers +import os +import tempfile +import unittest + +from senaite.astm import logger +from senaite.astm.cli import astm_server + + +class _Args(object): + """Minimal stand-in for the argparse Namespace.""" + + def __init__(self, **kw): + self.__dict__.update(kw) + + +class LogRotationTest(unittest.TestCase): + + def test_constants_are_sane(self): + # 5-byte rotation was a real bug in the legacy server. + self.assertGreaterEqual(astm_server.LOGFILE_MAX_BYTES, 1024 * 1024) + self.assertGreaterEqual(astm_server.LOGFILE_BACKUP_COUNT, 1) + + def test_configure_logging_does_not_rotate_per_record(self): + with tempfile.TemporaryDirectory() as tmp: + logfile = os.path.join(tmp, "server.log") + args = _Args(logfile=logfile, verbose=False) + + # Snapshot existing handlers so we can detach what we add. + before = list(logger.handlers) + try: + astm_server.configure_logging(args) + # Find the rotating handler we just attached. + rotating = [h for h in logger.handlers + if isinstance( + h, logging.handlers.RotatingFileHandler)] + self.assertEqual(len(rotating), 1) + handler = rotating[0] + self.assertEqual( + handler.maxBytes, astm_server.LOGFILE_MAX_BYTES) + self.assertEqual( + handler.backupCount, + astm_server.LOGFILE_BACKUP_COUNT) + + # Emit a few records — none should rotate. + for _ in range(20): + logger.info("a log line that easily exceeds 5 bytes") + handler.flush() + + rotated = [ + name for name in os.listdir(tmp) + if name.startswith("server.log.") + ] + self.assertEqual(rotated, []) + finally: + # Restore the logger to its pre-test state so other + # tests are not noisy. + for h in list(logger.handlers): + if h not in before: + logger.removeHandler(h) + + +class DrainTasksTest(unittest.IsolatedAsyncioTestCase): + + async def test_returns_immediately_when_empty(self): + await astm_server._drain_tasks(set(), grace_seconds=5) + + async def test_waits_for_inflight_task(self): + completed = [] + + async def slow(): + await asyncio.sleep(0.05) + completed.append(True) + + task = asyncio.create_task(slow()) + await astm_server._drain_tasks({task}, grace_seconds=2) + self.assertEqual(completed, [True]) + self.assertTrue(task.done()) + + async def test_cancels_tasks_that_exceed_grace(self): + async def stuck(): + await asyncio.sleep(60) + + task = asyncio.create_task(stuck()) + await astm_server._drain_tasks({task}, grace_seconds=0.05) + self.assertTrue(task.cancelled()) + + +class FrameCallbackTest(unittest.IsolatedAsyncioTestCase): + + async def test_callback_runs_pipeline_against_wrapped_envelope(self): + seen_envelopes = [] + + async def capture_handler(envelope): + seen_envelopes.append(envelope) + + from senaite.astm.core.pipeline import Pipeline + + pipeline = Pipeline([capture_handler]) + loop = asyncio.get_running_loop() + task_set = set() + callback = astm_server.make_frame_callback( + loop, pipeline, task_set) + + frames = [ + b"\x021H|\\^&|||C111^Roche^c111^4.2.2.1730^1^13147|||||" + b"host|RSUPL^REAL|P|1|20230727162028\r\x179B\r\n", + b"\x027L|1|N\r\x030A\r\n", + ] + callback("127.0.0.1:11111", frames) + + # Task must be tracked synchronously so shutdown can wait + self.assertEqual(len(task_set), 1) + + # Drain via the production helper to prove the contract holds. + await astm_server._drain_tasks(task_set, grace_seconds=2) + + self.assertEqual(len(seen_envelopes), 1) + self.assertEqual(len(seen_envelopes[0].H), 1) + self.assertEqual(task_set, set()) + + async def test_wrap_failure_does_not_crash_dispatch(self): + called = [] + + async def handler(envelope): + called.append(envelope) + + from senaite.astm.core.pipeline import Pipeline + + pipeline = Pipeline([handler]) + loop = asyncio.get_running_loop() + task_set = set() + callback = astm_server.make_frame_callback( + loop, pipeline, task_set) + + # Garbage frames that will not parse. + callback("127.0.0.1:11111", [b"not-a-frame"]) + await astm_server._drain_tasks(task_set, grace_seconds=2) + + self.assertEqual(called, []) + + +class GracefulShutdownTest(unittest.IsolatedAsyncioTestCase): + """Boot the server, dispatch a slow in-flight task, request + shutdown, and assert the task ran to completion before + ``amain`` returned.""" + + PORT = 7984 + + async def test_inflight_task_completes_before_amain_returns(self): + completed = asyncio.Event() + observed_during_shutdown = [] + + async def slow_handler(envelope): + # Sleep across the shutdown moment to prove drain waits. + await asyncio.sleep(0.2) + completed.set() + observed_during_shutdown.append(True) + + # Patch the pipeline builder so we don't need a live LIMS. + from senaite.astm.core.pipeline import Pipeline + + original_build = astm_server.build_pipeline + astm_server.build_pipeline = lambda args, session: Pipeline( + [slow_handler]) + try: + args = _Args( + listen="127.0.0.1", + port=self.PORT, + output=None, + url=None, + session=None, + shutdown_grace_seconds=5, + consumer="x", + message_format="json", + retries=1, + delay=0, + ) + + stop_event = asyncio.Event() + server_task = asyncio.create_task( + astm_server.amain(args, stop_event=stop_event)) + + # Give the server a moment to start listening. + await asyncio.sleep(0.05) + + # Send one full ASTM session to trigger the slow handler. + from senaite.astm.constants import ACK, ENQ, EOT + reader, writer = await asyncio.open_connection( + "127.0.0.1", self.PORT) + writer.write(ENQ) + await writer.drain() + self.assertEqual(await reader.read(100), ACK) + # Single non-chunked terminator frame — enough to put the + # protocol's EOT into "has messages" mode. ETX (\x03) + # rather than ETB (\x17) keeps it out of chunked mode. + frame = b"\x021L|1|N\r\x0304\r\n" + writer.write(frame) + await writer.drain() + self.assertEqual(await reader.read(100), ACK) + writer.write(EOT) + await writer.drain() + writer.close() + await writer.wait_closed() + + # Wait until the slow handler is in-flight, then request + # graceful shutdown via the test-injected stop event. + await asyncio.sleep(0.05) + stop_event.set() + + await asyncio.wait_for(server_task, timeout=5) + self.assertTrue(completed.is_set()) + self.assertEqual(observed_during_shutdown, [True]) + finally: + astm_server.build_pipeline = original_build + + +if __name__ == "__main__": + unittest.main() diff --git a/src/senaite/astm/tests/test_spotchem_el.py b/src/senaite/astm/tests/test_spotchem_el.py index b941547..1d15c87 100644 --- a/src/senaite/astm/tests/test_spotchem_el.py +++ b/src/senaite/astm/tests/test_spotchem_el.py @@ -4,7 +4,7 @@ from unittest.mock import Mock from senaite.astm import codec -from senaite.astm.protocol import ASTMProtocol +from senaite.astm.transports.astm.protocol import ASTMProtocol from senaite.astm.tests.base import ASTMTestBase from senaite.astm.wrapper import Wrapper from senaite.astm.instruments import spotchem_el @@ -28,7 +28,7 @@ async def asyncSetUp(self): # Mock transport and protocol objects self.transport = self.get_mock_transport() self.protocol.transport = self.transport - self.mapping = spotchem_el.get_mapping() + self.mapping = spotchem_el.INSTRUMENT.record_map def get_mock_transport(self, ip="127.0.0.1", port=12345): transport = MagicMock() diff --git a/src/senaite/astm/tests/test_sysmex_xn550.py b/src/senaite/astm/tests/test_sysmex_xn550.py index 493989e..4c5f687 100644 --- a/src/senaite/astm/tests/test_sysmex_xn550.py +++ b/src/senaite/astm/tests/test_sysmex_xn550.py @@ -7,7 +7,7 @@ from senaite.astm.constants import ACK from senaite.astm.constants import ENQ from senaite.astm.instruments import sysmex_xn -from senaite.astm.protocol import ASTMProtocol +from senaite.astm.transports.astm.protocol import ASTMProtocol from senaite.astm.tests.base import ASTMTestBase from senaite.astm.wrapper import Wrapper @@ -27,7 +27,7 @@ async def asyncSetUp(self): # Mock transport and protocol objects self.transport = self.get_mock_transport() self.protocol.transport = self.transport - self.mapping = sysmex_xn.get_mapping() + self.mapping = sysmex_xn.INSTRUMENT.record_map def get_mock_transport(self, ip="127.0.0.1", port=12345): transport = MagicMock() diff --git a/src/senaite/astm/tests/test_yumizen_h5xx.py b/src/senaite/astm/tests/test_yumizen_h5xx.py index 505bc32..1db9ce2 100644 --- a/src/senaite/astm/tests/test_yumizen_h5xx.py +++ b/src/senaite/astm/tests/test_yumizen_h5xx.py @@ -8,7 +8,7 @@ from senaite.astm.constants import ACK from senaite.astm.constants import ENQ from senaite.astm.instruments import horiba_yumizen_h5xx -from senaite.astm.protocol import ASTMProtocol +from senaite.astm.transports.astm.protocol import ASTMProtocol from senaite.astm.tests.base import ASTMTestBase from senaite.astm.wrapper import Wrapper @@ -163,7 +163,7 @@ async def asyncSetUp(self): # Mock transport and protocol objects self.transport = self.get_mock_transport() self.protocol.transport = self.transport - self.mapping = horiba_yumizen_h5xx.get_mapping() + self.mapping = horiba_yumizen_h5xx.INSTRUMENT.record_map def get_mock_transport(self, ip="127.0.0.1", port=12345): transport = MagicMock() diff --git a/src/senaite/astm/transports/__init__.py b/src/senaite/astm/transports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/senaite/astm/transports/astm/__init__.py b/src/senaite/astm/transports/astm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/senaite/astm/transports/astm/framing.py b/src/senaite/astm/transports/astm/framing.py new file mode 100644 index 0000000..4cc7c88 --- /dev/null +++ b/src/senaite/astm/transports/astm/framing.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +"""ASTM frame-level byte operations. + +This module is the canonical home of the ASTM transport's byte-level +helpers: chunked-frame detection, checksum validation, frame join / +split. The functions live in :mod:`senaite.astm.utils` for historical +reasons and are re-exported here so the ASTM transport package owns its +own framing surface. +""" + +from senaite.astm.utils import is_chunked_message +from senaite.astm.utils import join +from senaite.astm.utils import make_checksum +from senaite.astm.utils import split +from senaite.astm.utils import split_message +from senaite.astm.utils import validate_checksum + +__all__ = [ + "is_chunked_message", + "join", + "make_checksum", + "split", + "split_message", + "validate_checksum", +] diff --git a/src/senaite/astm/transports/astm/protocol.py b/src/senaite/astm/transports/astm/protocol.py new file mode 100644 index 0000000..2442c08 --- /dev/null +++ b/src/senaite/astm/transports/astm/protocol.py @@ -0,0 +1,234 @@ +# -*- coding: utf-8 -*- +"""ASTM-over-TCP transport. + +A slim :class:`asyncio.Protocol` that owns only the framing state +machine: ENQ/ACK/NAK/STX/EOT handling, chunked-frame reassembly, +checksum validation, and the per-connection inactivity timer. Once an +EOT closes a session, the collected frames are handed to a caller- +supplied ``frame_callback``. Wrapping into an :class:`Envelope`, +serialisation, queueing, disk capture and LIMS push all live outside +this transport (see :mod:`senaite.astm.cli.astm_server` and +:mod:`senaite.astm.core.pipeline`). +""" + +import asyncio + +from senaite.astm import logger +from senaite.astm.constants import ACK +from senaite.astm.constants import ENQ +from senaite.astm.constants import EOT +from senaite.astm.constants import NAK +from senaite.astm.constants import STX +from senaite.astm.core.instrument import find_raw_data_handler +from senaite.astm.exceptions import InvalidState +from senaite.astm.exceptions import NotAccepted +from senaite.astm.transports.astm.framing import is_chunked_message +from senaite.astm.transports.astm.framing import join +from senaite.astm.transports.astm.framing import validate_checksum + +TIMEOUT = 15 + + +class ASTMProtocol(asyncio.Protocol): + """ASTM transport protocol. + + Each TCP connection gets its own instance. Complete sessions + (ENQ ... EOT) are handed off via ``frame_callback(client, frames)`` + where ``frames`` is the list of validated, reassembled frame + bytes in arrival order. + """ + + def __init__(self, frame_callback=None, timeout=TIMEOUT): + logger.debug("ASTMProtocol:constructor") + self.frame_callback = frame_callback + self.timeout = timeout + + self.loop = None + self.transport = None + self.client = None + self.timer = None + self.chunks = [] + self.messages = [] + self.in_transfer_state = False + + # ------------------------------------------------------------------ + # asyncio.Protocol callbacks + # ------------------------------------------------------------------ + + def connection_made(self, transport): + # NOTE: ``asyncio.get_event_loop()`` is preserved here for + # behavioural parity with the legacy protocol; PR-G replaces + # this with ``asyncio.get_running_loop()`` and a properly + # async ``main()``. + self.loop = asyncio.get_event_loop() + self.transport = transport + self.client = self.get_client_key(transport) + logger.debug("Connection from {!s}".format(self.client)) + + def connection_lost(self, ex): + logger.warning("Lost connection for {!s}".format(self.client)) + self.close_connection() + + def data_received(self, data): + logger.debug("-> Data received from {!s}: {!r}".format( + self.client, data)) + self.restart_timer() + response = self.handle_data(data) + if response is not None: + logger.debug("<- Sending response: {!r}".format(response)) + self.transport.write(response) + + # ------------------------------------------------------------------ + # Timer management + # ------------------------------------------------------------------ + + def start_timer(self): + self.timer = self.loop.call_later(self.timeout, self.on_timeout) + + def cancel_timer(self): + if self.timer is None: + return + self.timer.cancel() + + def restart_timer(self): + self.cancel_timer() + self.start_timer() + + def on_timeout(self): + logger.warning( + "Connection for {!r} timed out after {!r}s: Closing..." + .format(self.client, self.timeout)) + self.close_connection() + + # ------------------------------------------------------------------ + # Connection / session lifecycle + # ------------------------------------------------------------------ + + def get_client_key(self, transport): + peername = transport.get_extra_info("peername") + return "{:s}:{:d}".format(*peername) + + def close_connection(self): + self.discard_env() + if self.transport is not None: + self.transport.close() + + def discard_chunked_messages(self): + self.chunks = [] + + def discard_env(self): + self.chunks = [] + self.messages = [] + self.in_transfer_state = False + + # ------------------------------------------------------------------ + # Byte-level dispatch + # ------------------------------------------------------------------ + + def handle_data(self, data): + # First chance: a registered instrument may own this raw, + # non-ASTM packet (mini_vidas, spotchem_el). + instrument = find_raw_data_handler(data) + if instrument is not None: + return instrument.handle_raw_data(self, data) + + if data.startswith(ENQ): + return self.on_enq(data) + if data.startswith(ACK): + return self.on_ack(data) + if data.startswith(NAK): + return self.on_nak(data) + if data.startswith(EOT): + return self.on_eot(data) + if data.startswith(STX): + return self.on_message(data) + return self.default_handler(data) + + def default_handler(self, data): + logger.error("Unable to dispatch data: %r", data) + + def on_enq(self, data): + logger.debug("on_enq: %r", data) + if self.in_transfer_state: + logger.error("ENQ is not expected") + return NAK + self.in_transfer_state = True + return ACK + + def on_ack(self, data): + logger.debug("on_ack: %r", data) + raise NotAccepted("Server should not be ACKed.") + + def on_nak(self, data): + logger.debug("on_nak: %r", data) + raise NotAccepted("Server should not be NAKed.") + + def on_eot(self, data): + logger.debug("on_eot: %r", data) + + if not self.in_transfer_state: + self.close_connection() + raise InvalidState("Server is not ready to accept EOT message.") + + self.cancel_timer() + + # XXX: Seen from Yumizen H550: EOT right after ENQ. + # Maybe this is some kind of keepalive? + if not self.messages: + self.discard_env() + return + + frames = list(self.messages) + self.dispatch_frames(frames) + self.discard_env() + + def on_message(self, data): + logger.debug("on_message: %r", data) + if not self.in_transfer_state: + self.discard_chunked_messages() + return NAK + try: + self.handle_message(data) + return ACK + except Exception as exc: + logger.error("Error occurred on message handling. {!r}" + .format(exc)) + return NAK + + def handle_message(self, message): + full_message = None + is_chunked_transfer = is_chunked_message(message) + + if is_chunked_transfer: + self.chunks.append(message) + elif self.chunks: + self.chunks.append(message) + full_message = join(self.chunks) + self.discard_chunked_messages() + else: + full_message = message + + if not full_message: + return + + if not validate_checksum(full_message): + raise NotAccepted("Checksum failed for '%r'" % full_message) + + self.messages.append(full_message) + + # ------------------------------------------------------------------ + # Frame dispatch + # ------------------------------------------------------------------ + + def dispatch_frames(self, frames): + """Hand the completed session's frames to the registered + callback. Errors in the callback do not affect the transport. + """ + if self.frame_callback is None: + logger.debug("No frame_callback registered; dropping %d frames", + len(frames)) + return + try: + self.frame_callback(self.client, frames) + except Exception as exc: + logger.error("frame_callback raised %r; frames dropped", exc) diff --git a/src/senaite/astm/transports/hl7/__init__.py b/src/senaite/astm/transports/hl7/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/senaite/astm/transports/hl7/framing.py b/src/senaite/astm/transports/hl7/framing.py new file mode 100644 index 0000000..76d4c4f --- /dev/null +++ b/src/senaite/astm/transports/hl7/framing.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +"""MLLP (Minimal Lower Layer Protocol) framing for HL7 messages. + +The HL7 spec ships HL7 v2 payloads inside a TCP block delimited by: + + ddd ... + +with: + + = 0x0B (ASCII vertical tab) + = 0x1C (ASCII file separator) + = 0x0D (ASCII carriage return) + +Inside the block the HL7 message itself uses ```` (0x0D) as the +inter-segment separator. The ```` that terminates the *block* and +the one that terminates the *last segment* are the same byte. + +Provided helpers: + +- :func:`extract_messages` parses a streaming buffer, returning every + complete MLLP block found and the unconsumed tail. +- :func:`wrap` wraps an HL7 payload in MLLP framing — used by + acknowledgement responses. +""" + +SB = b"\x0b" +EB = b"\x1c" +CR = b"\x0d" + +MLLP_END = EB + CR + + +def wrap(payload): + """Wrap an HL7 payload in MLLP framing. + + :param payload: HL7 message bytes. Inter-segment separators + (``\\r``) are caller's responsibility — this function does + not touch the payload itself. + :returns: ``SB + payload + EB + CR``. + """ + if isinstance(payload, str): + payload = payload.encode("utf-8") + return SB + payload + MLLP_END + + +def extract_messages(buffer): + """Parse a streaming buffer into complete MLLP blocks. + + :param buffer: bytes accumulated from the socket so far. Partial + blocks at the tail are returned to the caller for re-use on + the next read. + + :returns: A pair ``(messages, remainder)`` where ``messages`` is + a list of HL7 payload bytes (one entry per complete MLLP + block, with the SB / EB / CR markers stripped) and + ``remainder`` is the unconsumed buffer suffix. + + Bytes that appear before the first ``SB`` are dropped — devices + that send junk or framing leftovers shouldn't wedge the parser. + A bare ``EB CR`` with no preceding ``SB`` is also dropped. + """ + messages = [] + pos = 0 + while True: + start = buffer.find(SB, pos) + if start < 0: + # No further SB — nothing more we can do. Drop any + # pre-SB garbage by returning an empty remainder if + # nothing was buffered after the last consumed message. + remainder = buffer[pos:] + # If there is no partial frame in flight, drop pre-SB + # garbage entirely. + if SB not in remainder: + remainder = b"" + return messages, remainder + + end = buffer.find(MLLP_END, start + 1) + if end < 0: + # Partial frame: keep everything from SB onwards for the + # next read. + return messages, buffer[start:] + + payload = buffer[start + 1:end] + messages.append(payload) + pos = end + len(MLLP_END) diff --git a/src/senaite/astm/transports/hl7/parser.py b/src/senaite/astm/transports/hl7/parser.py new file mode 100644 index 0000000..b5ac3d7 --- /dev/null +++ b/src/senaite/astm/transports/hl7/parser.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +"""HL7 v2 → :class:`Envelope` parser. + +Turns a raw HL7 message (bytes, as captured by +:mod:`senaite.astm.transports.hl7.protocol`) into the same +:class:`Envelope` shape the ASTM transport produces, so downstream +consumers depend on one schema regardless of which transport the +device speaks. + +Segment-to-bucket mapping: + +================== ============================== +HL7 segment Envelope bucket +================== ============================== +``MSH`` :attr:`Envelope.H` +``PID`` :attr:`Envelope.P` +``OBR`` :attr:`Envelope.O` +``OBX`` :attr:`Envelope.R` +``NTE`` :attr:`Envelope.C` +================== ============================== + +The raw HL7 text lands in :attr:`Metadata.hl7` so disk capture and +"send the original bytes" flows still work without re-encoding. + +This module is intentionally lean: it does not interpret OBX values, +units, or flags. The HemoScreen-specific keyword mapping and +``OBR-4`` routing live in the instrument adapter (PR-8). +""" + +import hl7 + +from senaite.astm.core.envelope import Envelope, Metadata + +DEFAULT_ENCODING = "utf-8" + +# Bucket names mirror the ASTM record types the Envelope already +# carries. Mapping HL7 segments onto them keeps the public envelope +# schema transport-agnostic. +SEGMENT_BUCKETS = { + "MSH": "H", + "PID": "P", + "OBR": "O", + "OBX": "R", + "NTE": "C", +} + + +def _decode(raw): + """Return ``raw`` as a string regardless of input type.""" + if isinstance(raw, bytes): + return raw.decode(DEFAULT_ENCODING, errors="replace") + return raw + + +def _normalise_segments(text): + """Ensure inter-segment terminators are ``\\r``. + + Captured files may end up with mixed line endings depending on + how they were stored on disk. HL7 itself uses ``\\r``. + """ + return text.replace("\r\n", "\r").replace("\n", "\r") + + +def _segment_to_dict(segment): + """Convert one :class:`hl7.Segment` into ``{ "1": ..., "2": ... }``. + + Keys are stringified sequence numbers; fields with subcomponents + are rendered as plain strings (joined by the original delimiter + characters preserved by the ``hl7`` package). + + HL7's MSH numbering is irregular (MSH-1 is the field separator, + MSH-2 is the encoding characters), but the ``hl7`` package + transparently aligns indices so segment[3] is MSH-3, segment[1] + is PID-1 etc. We mirror those indices. + """ + out = {} + # Skip index 0 — that's the segment name itself. + for idx in range(1, len(segment)): + out[str(idx)] = str(segment[idx]) + return out + + +def parse(raw): + """Parse a raw HL7 v2 message into an :class:`Envelope`. + + :param raw: HL7 bytes (or string). MLLP framing must already be + stripped by the transport layer — this function expects only + the inner payload (segments separated by ``\\r`` or ``\\n``). + :returns: A fully-populated :class:`Envelope`. The raw payload + is stored verbatim in :attr:`Metadata.hl7`. + :raises ValueError: when the input cannot be parsed as HL7 at + all (no MSH segment, etc.). + """ + text = _normalise_segments(_decode(raw)) + if not text.endswith("\r"): + text = text + "\r" + + try: + message = hl7.parse(text) + except Exception as exc: + raise ValueError( + "Failed to parse HL7 message: {}".format(exc)) from exc + + buckets = {bucket: [] for bucket in SEGMENT_BUCKETS.values()} + for segment in message: + name = str(segment[0]) + bucket = SEGMENT_BUCKETS.get(name) + if bucket is None: + # Unknown segment — preserve as a generic dict under its + # own key so consumers can still see it without us + # silently dropping data. + continue + buckets[bucket].append(_segment_to_dict(segment)) + + metadata = Metadata(hl7=text.rstrip("\r")) + return Envelope(metadata=metadata, **buckets) diff --git a/src/senaite/astm/transports/hl7/protocol.py b/src/senaite/astm/transports/hl7/protocol.py new file mode 100644 index 0000000..8184efa --- /dev/null +++ b/src/senaite/astm/transports/hl7/protocol.py @@ -0,0 +1,156 @@ +# -*- coding: utf-8 -*- +"""HL7-over-MLLP transport. + +A slim :class:`asyncio.Protocol` that buffers incoming TCP bytes, +extracts complete MLLP-framed HL7 messages, dispatches each via a +caller-supplied ``frame_callback``, and writes back a communication- +level :rfc:`HL7 ACK^R01` for every message it received. + +The HL7 spec (HemoScreen HL7 Connectivity Protocol §3.2) mandates an +ACK before the device will send the next message, so even a +passthrough listener must respond. Parsing the inbound message +beyond the MSH header is intentionally out of scope here — the +parser layer (PR-7) takes the raw bytes and turns them into the +typed envelope. +""" + +import asyncio +from datetime import datetime + +from senaite.astm import logger +from senaite.astm.transports.hl7.framing import extract_messages +from senaite.astm.transports.hl7.framing import wrap + +HL7_VERSION = "2.4" +SEGMENT_SEPARATOR = b"\r" +FIELD_SEPARATOR = b"|" + + +def _now_hl7(): + return datetime.now().strftime("%Y%m%d%H%M%S") + + +def _parse_msh(message): + """Return a minimal MSH summary used to build the ACK. + + Only the fields the ACK depends on are extracted: the encoding + characters (MSH-2) and the message control ID (MSH-10). Any + parse failure returns ``(b"^~\\&", b"")`` so the protocol can + still respond with a defaulted ACK. + """ + segments = message.split(SEGMENT_SEPARATOR) + if not segments or not segments[0].startswith(b"MSH"): + return b"^~\\&", b"" + msh = segments[0] + fields = msh.split(FIELD_SEPARATOR) + # MSH-2 is the encoding-characters string. Note that MSH-1 is the + # field separator itself which is consumed when splitting, so the + # encoding characters land at index 1. + encoding = fields[1] if len(fields) > 1 else b"^~\\&" + # MSH-10 (message control ID) sits at index 9 after the split + # (since MSH-1 / MSH-2 are merged into the split sequence as one + # leading element). + control_id = fields[9] if len(fields) > 9 else b"" + return encoding, control_id + + +def build_ack(message, code="AA"): + """Build a communication-level ACK^R01 for ``message``. + + :param message: The HL7 payload that just arrived (bytes, + unwrapped from MLLP). + :param code: ``"AA"`` for application accept, ``"AE"`` for + application error. The HemoScreen spec also defines + ``"CA"`` / ``"CE"`` for comm-level ACK/NAK but the device + accepts AA/AE in practice. + :returns: HL7 bytes (no MLLP framing — :func:`framing.wrap` + is the caller's job). + """ + encoding, control_id = _parse_msh(message) + if isinstance(code, str): + code = code.encode("ascii") + timestamp = _now_hl7().encode("ascii") + version = HL7_VERSION.encode("ascii") + msh = b"|".join([ + b"MSH", + encoding, + b"", # MSH-3 sending application (optional in ACK) + b"", # MSH-4 sending facility + b"", # MSH-5 receiving application + b"", # MSH-6 receiving facility + timestamp, + b"", # MSH-8 security + b"ACK^R01", + control_id, + b"", # MSH-11 processing ID + version, + ]) + msa = b"|".join([b"MSA", code, control_id]) + return msh + b"\r" + msa + b"\r" + + +class HL7Protocol(asyncio.Protocol): + """HL7-over-MLLP listener. + + Each TCP connection gets its own instance. Bytes are buffered + across ``data_received`` calls and parsed greedily into MLLP + blocks. For every complete block the protocol: + + 1. invokes ``frame_callback(client, hl7_bytes)`` (must not raise); + 2. responds with an MLLP-wrapped ACK^R01. + """ + + def __init__(self, frame_callback=None): + logger.debug("HL7Protocol:constructor") + self.frame_callback = frame_callback + self.transport = None + self.client = None + self.buffer = b"" + + def connection_made(self, transport): + self.transport = transport + self.client = self._client_key(transport) + logger.debug("HL7 connection from %s", self.client) + + def connection_lost(self, ex): + logger.warning("Lost HL7 connection for %s", self.client) + self.buffer = b"" + + def data_received(self, data): + logger.debug("-> HL7 data from %s: %d bytes", self.client, len(data)) + self.buffer += data + + messages, self.buffer = extract_messages(self.buffer) + for message in messages: + self._dispatch(message) + self._respond_ack(message) + + @staticmethod + def _client_key(transport): + peername = transport.get_extra_info("peername") + return "{:s}:{:d}".format(*peername) + + def _dispatch(self, message): + if self.frame_callback is None: + logger.debug( + "No frame_callback registered; dropping %d-byte HL7 " + "message", len(message)) + return + try: + self.frame_callback(self.client, message) + except Exception as exc: + logger.error( + "HL7 frame_callback raised %r; message dropped", exc) + + def _respond_ack(self, message): + try: + ack = build_ack(message, code="AA") + except Exception as exc: + logger.error( + "Failed to build ACK for %s: %r — closing connection", + self.client, exc) + self.transport.close() + return + framed = wrap(ack) + logger.debug("<- HL7 ACK to %s: %d bytes", self.client, len(framed)) + self.transport.write(framed) diff --git a/src/senaite/astm/utils.py b/src/senaite/astm/utils.py index 08e1bfa..8c16fba 100644 --- a/src/senaite/astm/utils.py +++ b/src/senaite/astm/utils.py @@ -3,6 +3,7 @@ import os import time from datetime import datetime +from itertools import zip_longest from pathlib import Path from senaite.astm import logger @@ -13,11 +14,6 @@ from senaite.astm.constants import LF from senaite.astm.constants import STX -try: - from itertools import izip_longest -except ImportError: # Python 3 - from itertools import zip_longest as izip_longest - def u(s): if isinstance(s, bytes): @@ -34,8 +30,14 @@ def f(s, e="utf-8", **kw): CRLF=u(CRLF), **kw).encode(e) -def write_message(message, path, dateformat="%Y-%m-%d_%H:%M:%S", ext=".txt"): +def write_message(message, path, dateformat="%Y-%m-%d_%H:%M:%S.%f", + ext=".txt"): """Write ASTM Message to file + + The default ``dateformat`` includes microseconds so two messages + that arrive within the same second do not silently overwrite + each other on disk. The HemoScreen HL7 instrument, for example, + pushes its OBS/LQC/PRF triplet within a few hundred milliseconds. """ path = Path(path) if not path.exists(): @@ -97,7 +99,7 @@ def validate_checksum(message): # generate the checksum for the frame ccs = make_checksum(frame) if cs != ccs: - logger.warn("Expected checksum '%s', got '%s'" % (cs, ccs)) + logger.warning("Expected checksum '%s', got '%s'" % (cs, ccs)) return False return True @@ -166,7 +168,7 @@ def split(msg, size): def make_chunks(s, n): iter_bytes = (s[i:i + 1] for i in range(len(s))) return [b''.join(item) - for item in izip_longest(*[iter_bytes] * n, fillvalue=b'')] + for item in zip_longest(*[iter_bytes] * n, fillvalue=b'')] class CleanupDict(dict): diff --git a/src/senaite/astm/wrapper.py b/src/senaite/astm/wrapper.py index a47ea41..3fe672b 100644 --- a/src/senaite/astm/wrapper.py +++ b/src/senaite/astm/wrapper.py @@ -1,14 +1,14 @@ # -*- coding: utf-8 -*- -import json -import pkgutil -import re from collections import defaultdict from senaite.astm import codec -from senaite.astm import instruments +from senaite.astm import instruments # noqa: F401 (triggers registry) from senaite.astm import records from senaite.astm.constants import ENCODING +from senaite.astm.core.envelope import Envelope +from senaite.astm.core.envelope import Metadata +from senaite.astm.core.instrument import find_instrument from senaite.astm.utils import split_message DEFAULT_MAPPING = { @@ -28,29 +28,25 @@ class Wrapper(object): """ def __init__(self, messages): self.messages = messages + self.instrument = None self.mapping = self.get_mapping(messages) - self.module = None def get_mapping(self, messages): - """Returns the record mapping for the message + """Return the record mapping for the message. + + Resolved against the instrument registry populated at import + time by :func:`senaite.astm.core.instrument.register_instrument`. + Falls back to :data:`DEFAULT_MAPPING` when no registered + instrument claims the header (e.g. unknown device, empty + message list). """ if not messages: return DEFAULT_MAPPING - header = messages[0] - - for importer, modname, ispkg in pkgutil.iter_modules( - instruments.__path__, instruments.__name__ + "."): - module = __import__(modname, fromlist="dummy") - # get the regular expression to match the header message - regex = getattr(module, "HEADER_RX", None) - if regex and re.match(regex, header.decode()): - mapping = getattr(module, "get_mapping", None) - if callable(mapping): - return mapping() - # remember the matching module - self.module = module - - return DEFAULT_MAPPING + instrument = find_instrument(messages[0]) + if instrument is None: + return DEFAULT_MAPPING + self.instrument = instrument + return dict(instrument.record_map) def to_lis2a(self, encoding=ENCODING): out = b"" @@ -63,52 +59,51 @@ def to_astm(self, encoding=ENCODING): out = b"\n".join(self.messages) return out.decode(encoding) - def to_dict(self): - """Convert the ASTM message to a dictionary - - Returns a dictionary where the key is the record type and the values is - a list of value dictionaries: + def to_envelope(self): + """Parse the ASTM messages into a typed :class:`Envelope`. - { - 'H': [{...}], - ... - 'L': [{...}], - } + See :mod:`senaite.astm.core.envelope` for the schema and + the contract guarantees. """ - - # get the record mapping if provided mapping = self.get_mapping(self.messages) - # Prepare some metadata - metadata = { + + metadata_extras = { "astm": self.to_astm(), "lis2a": self.to_lis2a(), } - # Append additional metadata if provided by the module - metadata_func = getattr(self.module, "get_metadata", None) - if callable(metadata_func): - metadata.update(metadata_func(self)) - - # Output dictionary - out = defaultdict(list) - out["metadata"] = metadata + metadata_extras.update(self._collect_instrument_metadata()) + buckets = defaultdict(list) for message in self.messages: - records = codec.decode(message) - - for record in records: + for record in codec.decode(message): rtype = record[0] if rtype not in mapping: continue try: - wrapper = mapping[rtype](*record) + wrapped = mapping[rtype](*record) except ValueError as exc: raise ValueError("Could not wrap '%s' record! (%s)" % (rtype, str(exc))) - out[rtype].append(wrapper.to_dict()) + buckets[rtype].append(wrapped.to_dict()) + + return Envelope( + metadata=Metadata(**metadata_extras), + **buckets, + ) + + def _collect_instrument_metadata(self): + if self.instrument is None: + return {} + return dict(self.instrument.get_metadata(self) or {}) - return out + def to_dict(self): + """Return the envelope as a plain JSON-serialisable dict. + + Equivalent to :meth:`to_envelope` followed by + ``model_dump(mode="json")``. + """ + return self.to_envelope().model_dump(mode="json") def to_json(self): - data = json.dumps(self.to_dict()) - # Return the JSON encoded to bytes. - return data.encode() + """Return the envelope as JSON-encoded bytes.""" + return self.to_envelope().model_dump_json().encode()