diff --git a/README.md b/README.md index b447511b56f..7f8706a9c6d 100644 --- a/README.md +++ b/README.md @@ -127,14 +127,12 @@ await p.run_for_duration(speed=100, duration=30) Taking a measurement from a Mettler Toledo scale: ```python -from pylabrobot.scales import Scale -from pylabrobot.scales.mettler_toledo import MettlerToledoWXS205SDU +from pylabrobot.mettler_toledo import MTSICSDriver -backend = MettlerToledoWXS205SDU(port="/dev/cu.usbserial-110") -scale = Scale(backend=backend, size_x=0, size_y=0, size_z=0) +scale = MTSICSDriver(port="/dev/cu.usbserial-110") await scale.setup() -weight = await scale.get_weight() +weight = await scale.read_weight() ``` ### Heater shakers ([docs](https://docs.pylabrobot.org/stable/user_guide/01_material-handling/heating_shaking/heating_shaking.html)) diff --git a/docs/_static/devices.json b/docs/_static/devices.json index 88ce28db1eb..aa40beaa198 100644 --- a/docs/_static/devices.json +++ b/docs/_static/devices.json @@ -777,20 +777,798 @@ "oem": "https://liconic.com/products/stx.php" }, { - "id": "mettler-toledo-wxs205sdu", + "id": "mettler-toledo-mt-sics", "vendor": "Mettler Toledo", - "name": "WXS205SDU", + "name": "MT-SICS scales and weigh modules", + "models": [ + {"name": "AB104", "status": "wip"}, + {"name": "AB104-S", "status": "wip"}, + {"name": "AB104-S/FACT", "status": "wip"}, + {"name": "AB135-S", "status": "wip"}, + {"name": "AB135-S/FACT", "status": "wip"}, + {"name": "AB204", "status": "wip"}, + {"name": "AB204-S", "status": "wip"}, + {"name": "AB204-S/FACT", "status": "wip"}, + {"name": "AB265-S", "status": "wip"}, + {"name": "AB265-S/FACT", "status": "wip"}, + {"name": "AB304-S", "status": "wip"}, + {"name": "AB304-S/FACT", "status": "wip"}, + {"name": "AB54", "status": "wip"}, + {"name": "AB54-S", "status": "wip"}, + {"name": "AB54-S/FACT", "status": "wip"}, + {"name": "BBA422", "status": "wip"}, + {"name": "BBA425", "status": "wip"}, + {"name": "BBA432", "status": "wip"}, + {"name": "BBA439", "status": "wip"}, + {"name": "BBA442", "status": "wip"}, + {"name": "BBA462", "status": "wip"}, + {"name": "BBK422", "status": "wip"}, + {"name": "BBK432", "status": "wip"}, + {"name": "BBK442", "status": "wip"}, + {"name": "BBK462", "status": "wip"}, + {"name": "ICS226", "status": "wip"}, + {"name": "ICS241", "status": "wip"}, + {"name": "ICS425", "status": "wip"}, + {"name": "ICS426x", "status": "wip"}, + {"name": "ICS429", "status": "wip"}, + {"name": "ICS435", "status": "wip"}, + {"name": "ICS439", "status": "wip"}, + {"name": "ICS445", "status": "wip"}, + {"name": "ICS449", "status": "wip"}, + {"name": "ICS465", "status": "wip"}, + {"name": "ICS466x", "status": "wip"}, + {"name": "ICS469", "status": "wip"}, + {"name": "ICS629", "status": "wip"}, + {"name": "ICS639", "status": "wip"}, + {"name": "ICS649", "status": "wip"}, + {"name": "ICS669", "status": "wip"}, + {"name": "ICS685", "status": "wip"}, + {"name": "ICS689", "status": "wip"}, + {"name": "IND131", "status": "wip"}, + {"name": "IND221", "status": "wip"}, + {"name": "IND226", "status": "wip"}, + {"name": "IND226x", "status": "wip"}, + {"name": "IND231", "status": "wip"}, + {"name": "IND236", "status": "wip"}, + {"name": "IND246", "status": "wip"}, + {"name": "IND256x", "status": "wip"}, + {"name": "IND331", "status": "wip"}, + {"name": "IND360", "status": "wip"}, + {"name": "IND400", "status": "wip"}, + {"name": "IND425", "status": "wip"}, + {"name": "IND435", "status": "wip"}, + {"name": "IND445", "status": "wip"}, + {"name": "IND465", "status": "wip"}, + {"name": "IND560", "status": "wip"}, + {"name": "IND560x", "status": "wip"}, + {"name": "IND570", "status": "wip"}, + {"name": "IND690", "status": "wip"}, + {"name": "IND780", "status": "wip"}, + {"name": "IND930", "status": "wip"}, + {"name": "IND930SQC", "status": "wip"}, + {"name": "IND970-15", "status": "wip"}, + {"name": "IND970-19", "status": "wip"}, + {"name": "JE1002G", "status": "wip"}, + {"name": "JE1002GE", "status": "wip"}, + {"name": "JE1103C", "status": "wip"}, + {"name": "JE1103CE", "status": "wip"}, + {"name": "JE155DUG", "status": "wip"}, + {"name": "JE2002G", "status": "wip"}, + {"name": "JE2002GE", "status": "wip"}, + {"name": "JE203G", "status": "wip"}, + {"name": "JE203GE", "status": "wip"}, + {"name": "JE3002G", "status": "wip"}, + {"name": "JE3002GE", "status": "wip"}, + {"name": "JE303G", "status": "wip"}, + {"name": "JE303GE", "status": "wip"}, + {"name": "JE4002G", "status": "wip"}, + {"name": "JE4002GE", "status": "wip"}, + {"name": "JE5002G", "status": "wip"}, + {"name": "JE5002GE", "status": "wip"}, + {"name": "JE503C", "status": "wip"}, + {"name": "JE503CE", "status": "wip"}, + {"name": "JE503G", "status": "wip"}, + {"name": "JE503GE", "status": "wip"}, + {"name": "JE703C", "status": "wip"}, + {"name": "JE703CE", "status": "wip"}, + {"name": "JET1002G", "status": "wip"}, + {"name": "JET1002G/AED", "status": "wip"}, + {"name": "JET1003G", "status": "wip"}, + {"name": "JET1103C", "status": "wip"}, + {"name": "JET12002G", "status": "wip"}, + {"name": "JET1603C", "status": "wip"}, + {"name": "JET2001G/AED", "status": "wip"}, + {"name": "JET2002G", "status": "wip"}, + {"name": "JET3001G/AED", "status": "wip"}, + {"name": "JET3002G", "status": "wip"}, + {"name": "JET303G", "status": "wip"}, + {"name": "JET4002G", "status": "wip"}, + {"name": "JET503C", "status": "wip"}, + {"name": "JET6001G", "status": "wip"}, + {"name": "JET6001G/AED", "status": "wip"}, + {"name": "JET6002G", "status": "wip"}, + {"name": "JET602G/AED", "status": "wip"}, + {"name": "JET603G", "status": "wip"}, + {"name": "JET703C", "status": "wip"}, + {"name": "JL1502GE", "status": "wip"}, + {"name": "JL6001GE", "status": "wip"}, + {"name": "JL602GE", "status": "wip"}, + {"name": "JP105DUG", "status": "wip"}, + {"name": "JP1203C", "status": "wip"}, + {"name": "JP1603C", "status": "wip"}, + {"name": "JP2002G", "status": "wip"}, + {"name": "JP3002G", "status": "wip"}, + {"name": "JP303G", "status": "wip"}, + {"name": "JP4002G", "status": "wip"}, + {"name": "JP503C", "status": "wip"}, + {"name": "JP503G", "status": "wip"}, + {"name": "JP6002G", "status": "wip"}, + {"name": "JP703C", "status": "wip"}, + {"name": "JP8001G", "status": "wip"}, + {"name": "JP802G", "status": "wip"}, + {"name": "JS1203C", "status": "wip"}, + {"name": "JS1603C", "status": "wip"}, + {"name": "JS2002G", "status": "wip"}, + {"name": "JS3002G", "status": "wip"}, + {"name": "JS303G", "status": "wip"}, + {"name": "JS4002G", "status": "wip"}, + {"name": "JS503C", "status": "wip"}, + {"name": "JS503G", "status": "wip"}, + {"name": "JS6002G", "status": "wip"}, + {"name": "JS703C", "status": "wip"}, + {"name": "JS8001G", "status": "wip"}, + {"name": "JS802G", "status": "wip"}, + {"name": "LA104", "status": "wip"}, + {"name": "LA104E", "status": "wip"}, + {"name": "LA2002", "status": "wip"}, + {"name": "LA2002E", "status": "wip"}, + {"name": "LA203", "status": "wip"}, + {"name": "LA203E", "status": "wip"}, + {"name": "LA204", "status": "wip"}, + {"name": "LA204E", "status": "wip"}, + {"name": "LA4002", "status": "wip"}, + {"name": "LA4002E", "status": "wip"}, + {"name": "LA403", "status": "wip"}, + {"name": "LA403E", "status": "wip"}, + {"name": "LA84", "status": "wip"}, + {"name": "LA84E", "status": "wip"}, + {"name": "MA1002", "status": "wip"}, + {"name": "MA1002E", "status": "wip"}, + {"name": "MA103", "status": "wip"}, + {"name": "MA103E", "status": "wip"}, + {"name": "MA104", "status": "wip"}, + {"name": "MA104E", "status": "wip"}, + {"name": "MA12001L", "status": "wip"}, + {"name": "MA155DU", "status": "wip"}, + {"name": "MA16001L", "status": "wip"}, + {"name": "MA2002", "status": "wip"}, + {"name": "MA2002E", "status": "wip"}, + {"name": "MA2002P", "status": "wip"}, + {"name": "MA2002PE", "status": "wip"}, + {"name": "MA203", "status": "wip"}, + {"name": "MA203E", "status": "wip"}, + {"name": "MA204", "status": "wip"}, + {"name": "MA204E", "status": "wip"}, + {"name": "MA3002", "status": "wip"}, + {"name": "MA3002E", "status": "wip"}, + {"name": "MA303", "status": "wip"}, + {"name": "MA303E", "status": "wip"}, + {"name": "MA32000L", "status": "wip"}, + {"name": "MA32001L", "status": "wip"}, + {"name": "MA4002", "status": "wip"}, + {"name": "MA4002E", "status": "wip"}, + {"name": "MA5001", "status": "wip"}, + {"name": "MA5001E", "status": "wip"}, + {"name": "MA503", "status": "wip"}, + {"name": "MA503E", "status": "wip"}, + {"name": "MA54", "status": "wip"}, + {"name": "MA54E", "status": "wip"}, + {"name": "MA55", "status": "wip"}, + {"name": "MA6001P", "status": "wip"}, + {"name": "MA6001PE", "status": "wip"}, + {"name": "MA6002", "status": "wip"}, + {"name": "MA6002E", "status": "wip"}, + {"name": "MA602", "status": "wip"}, + {"name": "MA602E", "status": "wip"}, + {"name": "MA602P", "status": "wip"}, + {"name": "MA602PE", "status": "wip"}, + {"name": "MA95", "status": "wip"}, + {"name": "ME1002", "status": "wip"}, + {"name": "ME1002E", "status": "wip"}, + {"name": "ME1002T/00", "status": "wip"}, + {"name": "ME1002TE/00", "status": "wip"}, + {"name": "ME103", "status": "wip"}, + {"name": "ME103E", "status": "wip"}, + {"name": "ME103T/00", "status": "wip"}, + {"name": "ME103TE/00", "status": "wip"}, + {"name": "ME2002", "status": "wip"}, + {"name": "ME2002E", "status": "wip"}, + {"name": "ME2002T/00", "status": "wip"}, + {"name": "ME2002TE/00", "status": "wip"}, + {"name": "ME203", "status": "wip"}, + {"name": "ME203E", "status": "wip"}, + {"name": "ME203T/00", "status": "wip"}, + {"name": "ME203TE/00", "status": "wip"}, + {"name": "ME3002", "status": "wip"}, + {"name": "ME3002E", "status": "wip"}, + {"name": "ME3002T/00", "status": "wip"}, + {"name": "ME3002TE/00", "status": "wip"}, + {"name": "ME303", "status": "wip"}, + {"name": "ME303E", "status": "wip"}, + {"name": "ME303T/00", "status": "wip"}, + {"name": "ME303TE/00", "status": "wip"}, + {"name": "ME4001T/00", "status": "wip"}, + {"name": "ME4001TE/00", "status": "wip"}, + {"name": "ME4002", "status": "wip"}, + {"name": "ME4002E", "status": "wip"}, + {"name": "ME4002T/00", "status": "wip"}, + {"name": "ME4002TE/00", "status": "wip"}, + {"name": "ME403", "status": "wip"}, + {"name": "ME403E", "status": "wip"}, + {"name": "ME403T/00", "status": "wip"}, + {"name": "ME403TE/00", "status": "wip"}, + {"name": "ME5002T/00", "status": "wip"}, + {"name": "ME5002TE/00", "status": "wip"}, + {"name": "ME503T/00", "status": "wip"}, + {"name": "ME503TE/00", "status": "wip"}, + {"name": "ME6002T/00", "status": "wip"}, + {"name": "ME6002TE/00", "status": "wip"}, + {"name": "ME603T/00", "status": "wip"}, + {"name": "ME603TE/00", "status": "wip"}, + {"name": "ML1602T/00", "status": "wip"}, + {"name": "ML203T/00", "status": "wip"}, + {"name": "ML3001T/00", "status": "wip"}, + {"name": "ML3002T/00", "status": "wip"}, + {"name": "ML303T/00", "status": "wip"}, + {"name": "ML4002T/00", "status": "wip"}, + {"name": "ML503T/00", "status": "wip"}, + {"name": "ML6001T/00", "status": "wip"}, + {"name": "ML6002T/00", "status": "wip"}, + {"name": "ML802T/00", "status": "wip"}, + {"name": "MR1002", "status": "wip"}, + {"name": "MR104", "status": "wip"}, + {"name": "MR2002", "status": "wip"}, + {"name": "MR203", "status": "wip"}, + {"name": "MR204", "status": "wip"}, + {"name": "MR3002", "status": "wip"}, + {"name": "MR303", "status": "wip"}, + {"name": "MR304", "status": "wip"}, + {"name": "MR4002", "status": "wip"}, + {"name": "MR503", "status": "wip"}, + {"name": "MR6001", "status": "wip"}, + {"name": "MR6002", "status": "wip"}, + {"name": "MR603", "status": "wip"}, + {"name": "MS1003S", "status": "wip"}, + {"name": "MS1003TS", "status": "wip"}, + {"name": "MS1003TS/00", "status": "wip"}, + {"name": "MS104S", "status": "wip"}, + {"name": "MS104TS", "status": "wip"}, + {"name": "MS105", "status": "wip"}, + {"name": "MS105DU", "status": "wip"}, + {"name": "MS12001L", "status": "wip"}, + {"name": "MS12002TS", "status": "wip"}, + {"name": "MS12002TS/00", "status": "wip"}, + {"name": "MS15KLE", "status": "wip"}, + {"name": "MS15KLIPE", "status": "wip"}, + {"name": "MS16001L", "status": "wip"}, + {"name": "MS16001LE", "status": "wip"}, + {"name": "MS1602S", "status": "wip"}, + {"name": "MS1602SE", "status": "wip"}, + {"name": "MS1602TS", "status": "wip"}, + {"name": "MS1602TS/00", "status": "wip"}, + {"name": "MS204", "status": "wip"}, + {"name": "MS204S", "status": "wip"}, + {"name": "MS204TS", "status": "wip"}, + {"name": "MS205DU", "status": "wip"}, + {"name": "MS24KLIPE", "status": "wip"}, + {"name": "MS3001S", "status": "wip"}, + {"name": "MS3002S", "status": "wip"}, + {"name": "MS3002SE", "status": "wip"}, + {"name": "MS3002TS", "status": "wip"}, + {"name": "MS3002TS/00", "status": "wip"}, + {"name": "MS303S", "status": "wip"}, + {"name": "MS303SE", "status": "wip"}, + {"name": "MS303TS", "status": "wip"}, + {"name": "MS303TS/00", "status": "wip"}, + {"name": "MS304S", "status": "wip"}, + {"name": "MS304TS", "status": "wip"}, + {"name": "MS30KLE", "status": "wip"}, + {"name": "MS32000L", "status": "wip"}, + {"name": "MS32000LE", "status": "wip"}, + {"name": "MS32001L", "status": "wip"}, + {"name": "MS32001LE", "status": "wip"}, + {"name": "MS4002S", "status": "wip"}, + {"name": "MS4002SDR", "status": "wip"}, + {"name": "MS4002TS", "status": "wip"}, + {"name": "MS4002TS/00", "status": "wip"}, + {"name": "MS4002TSDR", "status": "wip"}, + {"name": "MS4002TSDR/00", "status": "wip"}, + {"name": "MS403S", "status": "wip"}, + {"name": "MS403TS", "status": "wip"}, + {"name": "MS403TS/00", "status": "wip"}, + {"name": "MS6001S", "status": "wip"}, + {"name": "MS6002S", "status": "wip"}, + {"name": "MS6002SDR", "status": "wip"}, + {"name": "MS6002TS", "status": "wip"}, + {"name": "MS6002TS/00", "status": "wip"}, + {"name": "MS6002TSDR", "status": "wip"}, + {"name": "MS6002TSDR/00", "status": "wip"}, + {"name": "MS603S", "status": "wip"}, + {"name": "MS603TS", "status": "wip"}, + {"name": "MS603TS/00", "status": "wip"}, + {"name": "MS8000S", "status": "wip"}, + {"name": "MS8000SE", "status": "wip"}, + {"name": "MS8001S", "status": "wip"}, + {"name": "MS8001SE", "status": "wip"}, + {"name": "MS8001TS", "status": "wip"}, + {"name": "MS8001TS/00", "status": "wip"}, + {"name": "MS802S", "status": "wip"}, + {"name": "MX104", "status": "wip"}, + {"name": "MX105", "status": "wip"}, + {"name": "MX105DU", "status": "wip"}, + {"name": "MX12001L", "status": "wip"}, + {"name": "MX12002", "status": "wip"}, + {"name": "MX1203", "status": "wip"}, + {"name": "MX1203N", "status": "wip"}, + {"name": "MX16001L", "status": "wip"}, + {"name": "MX2002", "status": "wip"}, + {"name": "MX204", "status": "wip"}, + {"name": "MX205DU", "status": "wip"}, + {"name": "MX303", "status": "wip"}, + {"name": "MX303N", "status": "wip"}, + {"name": "MX304", "status": "wip"}, + {"name": "MX32000L", "status": "wip"}, + {"name": "MX32001L", "status": "wip"}, + {"name": "MX4002", "status": "wip"}, + {"name": "MX6001", "status": "wip"}, + {"name": "MX6002", "status": "wip"}, + {"name": "MX6002DR", "status": "wip"}, + {"name": "MX603", "status": "wip"}, + {"name": "MX603N", "status": "wip"}, + {"name": "PB1501-L", "status": "wip"}, + {"name": "PB1501-S", "status": "wip"}, + {"name": "PB1501-S/FACT", "status": "wip"}, + {"name": "PB1502-L", "status": "wip"}, + {"name": "PB1502-S", "status": "wip"}, + {"name": "PB1502-S/FACT", "status": "wip"}, + {"name": "PB153-L", "status": "wip"}, + {"name": "PB153-S", "status": "wip"}, + {"name": "PB153-S/FACT", "status": "wip"}, + {"name": "PB3001-L", "status": "wip"}, + {"name": "PB3001-S", "status": "wip"}, + {"name": "PB3001-S/FACT", "status": "wip"}, + {"name": "PB3002-L", "status": "wip"}, + {"name": "PB3002-LDR", "status": "wip"}, + {"name": "PB3002-S", "status": "wip"}, + {"name": "PB3002-S/FACT", "status": "wip"}, + {"name": "PB3002-SDR", "status": "wip"}, + {"name": "PB3002-SDR/FACT", "status": "wip"}, + {"name": "PB303-L", "status": "wip"}, + {"name": "PB303-LDR", "status": "wip"}, + {"name": "PB303-S", "status": "wip"}, + {"name": "PB303-S/FACT", "status": "wip"}, + {"name": "PB303-SDR", "status": "wip"}, + {"name": "PB303-SDR/FACT", "status": "wip"}, + {"name": "PB4002-S", "status": "wip"}, + {"name": "PB4002-S/FACT", "status": "wip"}, + {"name": "PB403-S", "status": "wip"}, + {"name": "PB403-S/FACT", "status": "wip"}, + {"name": "PB5001-L", "status": "wip"}, + {"name": "PB5001-S", "status": "wip"}, + {"name": "PB5001-S/FACT", "status": "wip"}, + {"name": "PB503-S", "status": "wip"}, + {"name": "PB503-S/FACT", "status": "wip"}, + {"name": "PB602-L", "status": "wip"}, + {"name": "PB602-S", "status": "wip"}, + {"name": "PB602-S/FACT", "status": "wip"}, + {"name": "PB8000-L", "status": "wip"}, + {"name": "PB8000-S", "status": "wip"}, + {"name": "PB8000-S/FACT", "status": "wip"}, + {"name": "PB8001-L", "status": "wip"}, + {"name": "PB8001-S", "status": "wip"}, + {"name": "PB8001-S/FACT", "status": "wip"}, + {"name": "PG1003-S", "status": "wip"}, + {"name": "PG2002-S", "status": "wip"}, + {"name": "PG203-S", "status": "wip"}, + {"name": "PG3001-S", "status": "wip"}, + {"name": "PG4002-S", "status": "wip"}, + {"name": "PG403-S", "status": "wip"}, + {"name": "PG5002-S", "status": "wip"}, + {"name": "PG503-S", "status": "wip"}, + {"name": "PG6002-S", "status": "wip"}, + {"name": "PG603-S", "status": "wip"}, + {"name": "PG802-S", "status": "wip"}, + {"name": "PR10003MC", "status": "wip"}, + {"name": "PR1200", "status": "wip"}, + {"name": "PR1203", "status": "wip"}, + {"name": "PR2002", "status": "wip"}, + {"name": "PR2003DR", "status": "wip"}, + {"name": "PR2003MC", "status": "wip"}, + {"name": "PR2004MC", "status": "wip"}, + {"name": "PR203", "status": "wip"}, + {"name": "PR3001", "status": "wip"}, + {"name": "PR5001", "status": "wip"}, + {"name": "PR5002", "status": "wip"}, + {"name": "PR5002DR", "status": "wip"}, + {"name": "PR5003DU", "status": "wip"}, + {"name": "PR5003MC", "status": "wip"}, + {"name": "PR502", "status": "wip"}, + {"name": "PR503", "status": "wip"}, + {"name": "PR503DR", "status": "wip"}, + {"name": "PR504DR", "status": "wip"}, + {"name": "PR700", "status": "wip"}, + {"name": "PR8000", "status": "wip"}, + {"name": "PR8001", "status": "wip"}, + {"name": "PR8002", "status": "wip"}, + {"name": "PR8002DR", "status": "wip"}, + {"name": "PR802", "status": "wip"}, + {"name": "PR803", "status": "wip"}, + {"name": "SAG203F", "status": "wip"}, + {"name": "SAG204", "status": "wip"}, + {"name": "SAG245", "status": "wip"}, + {"name": "SAG285", "status": "wip"}, + {"name": "SB12001", "status": "wip"}, + {"name": "SB16000", "status": "wip"}, + {"name": "SB16001", "status": "wip"}, + {"name": "SB16001DR", "status": "wip"}, + {"name": "SB24001DR", "status": "wip"}, + {"name": "SB32000", "status": "wip"}, + {"name": "SB32001DR", "status": "wip"}, + {"name": "SB8000", "status": "wip"}, + {"name": "SB8001", "status": "wip"}, + {"name": "SLF615", "status": "wip"}, + {"name": "SLF630", "status": "wip"}, + {"name": "SLF660", "status": "wip"}, + {"name": "SPC", "status": "wip"}, + {"name": "SPD12002", "status": "wip"}, + {"name": "SPD12002-C", "status": "wip"}, + {"name": "SPE2003-C", "status": "wip"}, + {"name": "SPE4002-C", "status": "wip"}, + {"name": "SPE403-C", "status": "wip"}, + {"name": "SPE404-C", "status": "wip"}, + {"name": "SPE7002-C", "status": "wip"}, + {"name": "SR16000", "status": "wip"}, + {"name": "SR16001", "status": "wip"}, + {"name": "SR16001DR", "status": "wip"}, + {"name": "SR32000", "status": "wip"}, + {"name": "SR32001", "status": "wip"}, + {"name": "SR32001DR", "status": "wip"}, + {"name": "SR64000", "status": "wip"}, + {"name": "SR64001", "status": "wip"}, + {"name": "SR64001DR", "status": "wip"}, + {"name": "SR8001", "status": "wip"}, + {"name": "Viper EX", "status": "wip"}, + {"name": "Viper SC", "status": "wip"}, + {"name": "Viper SW", "status": "wip"}, + {"name": "WKC204C", "status": "wip"}, + {"name": "WKC6002C", "status": "wip"}, + {"name": "WKC603C", "status": "wip"}, + {"name": "WM123", "status": "wip"}, + {"name": "WM124", "status": "wip"}, + {"name": "WM3002", "status": "wip"}, + {"name": "WM3002X", "status": "wip"}, + {"name": "WM503", "status": "wip"}, + {"name": "WM6002", "status": "wip"}, + {"name": "WM6002X", "status": "wip"}, + {"name": "WMC15-SH", "status": "wip"}, + {"name": "WMC24-SH", "status": "wip"}, + {"name": "WMC25-SH", "status": "wip"}, + {"name": "WMF204C-L/IE", "status": "wip"}, + {"name": "WMF204C-L/PN", "status": "wip"}, + {"name": "WMF204C-W/IE", "status": "wip"}, + {"name": "WMF204C-W/PN", "status": "wip"}, + {"name": "WMF303C-L/IE", "status": "wip"}, + {"name": "WMF303C-L/PN", "status": "wip"}, + {"name": "WMF303C-W/IE", "status": "wip"}, + {"name": "WMF303C-W/PN", "status": "wip"}, + {"name": "WMS104C", "status": "wip"}, + {"name": "WMS1203C", "status": "wip"}, + {"name": "WMS204", "status": "wip"}, + {"name": "WMS4002", "status": "wip"}, + {"name": "WMS403", "status": "wip"}, + {"name": "WMS404C", "status": "wip"}, + {"name": "WMS404C-L", "status": "wip"}, + {"name": "WMS6002C", "status": "wip"}, + {"name": "WMS803", "status": "wip"}, + {"name": "WXS204", "status": "wip"}, + {"name": "WXS204S/15", "status": "wip"}, + {"name": "WXS204SV/15", "status": "wip"}, + {"name": "WXS205", "status": "wip"}, + {"name": "WXS205DU", "status": "wip"}, + {"name": "WXS205S/15", "status": "wip"}, + {"name": "WXS205SDU/15", "status": "full"}, + {"name": "WXS205SDUV/15", "status": "wip"}, + {"name": "WXS205SV/15", "status": "wip"}, + {"name": "WXS26", "status": "wip"}, + {"name": "WXS26DU", "status": "wip"}, + {"name": "WXS26S/15", "status": "wip"}, + {"name": "WXS26SDU/15", "status": "wip"}, + {"name": "WXS26SDUV/15", "status": "wip"}, + {"name": "WXS26SV/15", "status": "wip"}, + {"name": "WXSS204", "status": "wip"}, + {"name": "WXSS204/A", "status": "wip"}, + {"name": "WXSS204/M", "status": "wip"}, + {"name": "WXSS204V", "status": "wip"}, + {"name": "WXSS205", "status": "wip"}, + {"name": "WXSS205/A", "status": "wip"}, + {"name": "WXSS205/M", "status": "wip"}, + {"name": "WXSS205DU", "status": "wip"}, + {"name": "WXSS205DU/A", "status": "wip"}, + {"name": "WXSS205DU/M", "status": "wip"}, + {"name": "WXSS205DUV", "status": "wip"}, + {"name": "WXSS205V", "status": "wip"}, + {"name": "WXSS26", "status": "wip"}, + {"name": "WXSS26/A", "status": "wip"}, + {"name": "WXSS26/M", "status": "wip"}, + {"name": "WXSS26DU", "status": "wip"}, + {"name": "WXSS26DU/A", "status": "wip"}, + {"name": "WXSS26DU/M", "status": "wip"}, + {"name": "WXSS26DUV", "status": "wip"}, + {"name": "WXSS26V", "status": "wip"}, + {"name": "WXTP204", "status": "wip"}, + {"name": "WXTP204V", "status": "wip"}, + {"name": "WXTP205", "status": "wip"}, + {"name": "WXTP205DU", "status": "wip"}, + {"name": "WXTP205DUV", "status": "wip"}, + {"name": "WXTP205V", "status": "wip"}, + {"name": "WXTP26", "status": "wip"}, + {"name": "WXTP26DU", "status": "wip"}, + {"name": "WXTP26DUV", "status": "wip"}, + {"name": "WXTP26V", "status": "wip"}, + {"name": "WXTS204", "status": "wip"}, + {"name": "WXTS204/A", "status": "wip"}, + {"name": "WXTS204/M", "status": "wip"}, + {"name": "WXTS204V", "status": "wip"}, + {"name": "WXTS205", "status": "wip"}, + {"name": "WXTS205/A", "status": "wip"}, + {"name": "WXTS205/M", "status": "wip"}, + {"name": "WXTS205DU", "status": "wip"}, + {"name": "WXTS205DU/A", "status": "wip"}, + {"name": "WXTS205DU/M", "status": "wip"}, + {"name": "WXTS205DUV", "status": "wip"}, + {"name": "WXTS205V", "status": "wip"}, + {"name": "WXTS26", "status": "wip"}, + {"name": "WXTS26/A", "status": "wip"}, + {"name": "WXTS26/M", "status": "wip"}, + {"name": "WXTS26DU", "status": "wip"}, + {"name": "WXTS26DU/A", "status": "wip"}, + {"name": "WXTS26DU/M", "status": "wip"}, + {"name": "WXTS26DUV", "status": "wip"}, + {"name": "WXTS26V", "status": "wip"}, + {"name": "WXTS3DU", "status": "wip"}, + {"name": "WXTS3DU/A", "status": "wip"}, + {"name": "WXTS3DU/M", "status": "wip"}, + {"name": "XA105", "status": "wip"}, + {"name": "XA1502S", "status": "wip"}, + {"name": "XA204", "status": "wip"}, + {"name": "XA3001S", "status": "wip"}, + {"name": "XA3002S", "status": "wip"}, + {"name": "XA303S", "status": "wip"}, + {"name": "XA5002S", "status": "wip"}, + {"name": "XA503S", "status": "wip"}, + {"name": "XP105", "status": "wip"}, + {"name": "XP1202S", "status": "wip"}, + {"name": "XP1203S", "status": "wip"}, + {"name": "XP2001S", "status": "wip"}, + {"name": "XP2002S", "status": "wip"}, + {"name": "XP2003S", "status": "wip"}, + {"name": "XP203S", "status": "wip"}, + {"name": "XP204S", "status": "wip"}, + {"name": "XP205", "status": "wip"}, + {"name": "XP26", "status": "wip"}, + {"name": "XP2U", "status": "wip"}, + {"name": "XP4001S", "status": "wip"}, + {"name": "XP4002S", "status": "wip"}, + {"name": "XP404S", "status": "wip"}, + {"name": "XP5003S", "status": "wip"}, + {"name": "XP504", "status": "wip"}, + {"name": "XP56", "status": "wip"}, + {"name": "XP6", "status": "wip"}, + {"name": "XP6001S", "status": "wip"}, + {"name": "XP6002S", "status": "wip"}, + {"name": "XP603S", "status": "wip"}, + {"name": "XP8001S", "status": "wip"}, + {"name": "XP8002S", "status": "wip"}, + {"name": "XP802S", "status": "wip"}, + {"name": "XPE10001L", "status": "wip"}, + {"name": "XPE10001S", "status": "wip"}, + {"name": "XPE10002S", "status": "wip"}, + {"name": "XPE105", "status": "wip"}, + {"name": "XPE1202S", "status": "wip"}, + {"name": "XPE1203S", "status": "wip"}, + {"name": "XPE15002L", "status": "wip"}, + {"name": "XPE16001L", "status": "wip"}, + {"name": "XPE20002LDR", "status": "wip"}, + {"name": "XPE2002S", "status": "wip"}, + {"name": "XPE204", "status": "wip"}, + {"name": "XPE204S", "status": "wip"}, + {"name": "XPE205", "status": "wip"}, + {"name": "XPE205CDR", "status": "wip"}, + {"name": "XPE205DR", "status": "wip"}, + {"name": "XPE26", "status": "wip"}, + {"name": "XPE26C", "status": "wip"}, + {"name": "XPE26DR", "status": "wip"}, + {"name": "XPE3003S", "status": "wip"}, + {"name": "XPE3003SD5", "status": "wip"}, + {"name": "XPE303S", "status": "wip"}, + {"name": "XPE303SN", "status": "wip"}, + {"name": "XPE32000L", "status": "wip"}, + {"name": "XPE32001L", "status": "wip"}, + {"name": "XPE4001S", "status": "wip"}, + {"name": "XPE4002S", "status": "wip"}, + {"name": "XPE404S", "status": "wip"}, + {"name": "XPE5003S", "status": "wip"}, + {"name": "XPE504", "status": "wip"}, + {"name": "XPE505C", "status": "wip"}, + {"name": "XPE56C", "status": "wip"}, + {"name": "XPE6001S", "status": "wip"}, + {"name": "XPE6002S", "status": "wip"}, + {"name": "XPE6002SDR", "status": "wip"}, + {"name": "XPE6003SD5", "status": "wip"}, + {"name": "XPE603S", "status": "wip"}, + {"name": "XPE603SDR", "status": "wip"}, + {"name": "XPE603SN", "status": "wip"}, + {"name": "XPE603SNDR", "status": "wip"}, + {"name": "XPE64000L", "status": "wip"}, + {"name": "XPE64001L", "status": "wip"}, + {"name": "XPE8001S", "status": "wip"}, + {"name": "XPE8002S", "status": "wip"}, + {"name": "XPR10", "status": "wip"}, + {"name": "XPR10003SC", "status": "wip"}, + {"name": "XPR1004SC", "status": "wip"}, + {"name": "XPR105", "status": "wip"}, + {"name": "XPR105DUHR", "status": "wip"}, + {"name": "XPR106DUH", "status": "wip"}, + {"name": "XPR106DUHR", "status": "wip"}, + {"name": "XPR12001L", "status": "wip"}, + {"name": "XPR12001S", "status": "wip"}, + {"name": "XPR12002S", "status": "wip"}, + {"name": "XPR12003SD5", "status": "wip"}, + {"name": "XPR1202S", "status": "wip"}, + {"name": "XPR1203S", "status": "wip"}, + {"name": "XPR15002L", "status": "wip"}, + {"name": "XPR16001L", "status": "wip"}, + {"name": "XPR2", "status": "wip"}, + {"name": "XPR20002LDR", "status": "wip"}, + {"name": "XPR2002S", "status": "wip"}, + {"name": "XPR2003S", "status": "wip"}, + {"name": "XPR2003SC", "status": "wip"}, + {"name": "XPR2004SC", "status": "wip"}, + {"name": "XPR204", "status": "wip"}, + {"name": "XPR204E", "status": "wip"}, + {"name": "XPR204E/A", "status": "wip"}, + {"name": "XPR204E/M", "status": "wip"}, + {"name": "XPR204S", "status": "wip"}, + {"name": "XPR205", "status": "wip"}, + {"name": "XPR225DR", "status": "wip"}, + {"name": "XPR226CDR", "status": "wip"}, + {"name": "XPR226DR", "status": "wip"}, + {"name": "XPR26003LC", "status": "wip"}, + {"name": "XPR2U", "status": "wip"}, + {"name": "XPR2U/A", "status": "wip"}, + {"name": "XPR2U/M", "status": "wip"}, + {"name": "XPR3003S", "status": "wip"}, + {"name": "XPR3004SD5", "status": "wip"}, + {"name": "XPR303S", "status": "wip"}, + {"name": "XPR303SN", "status": "wip"}, + {"name": "XPR305DR", "status": "wip"}, + {"name": "XPR32000L", "status": "wip"}, + {"name": "XPR32001L", "status": "wip"}, + {"name": "XPR32003LD5C", "status": "wip"}, + {"name": "XPR36", "status": "wip"}, + {"name": "XPR36C", "status": "wip"}, + {"name": "XPR36DR", "status": "wip"}, + {"name": "XPR4001S", "status": "wip"}, + {"name": "XPR4002S", "status": "wip"}, + {"name": "XPR404S", "status": "wip"}, + {"name": "XPR5003S", "status": "wip"}, + {"name": "XPR5003SC", "status": "wip"}, + {"name": "XPR5004SC", "status": "wip"}, + {"name": "XPR504S", "status": "wip"}, + {"name": "XPR56", "status": "wip"}, + {"name": "XPR56C", "status": "wip"}, + {"name": "XPR56DR", "status": "wip"}, + {"name": "XPR6002S", "status": "wip"}, + {"name": "XPR6002SDR", "status": "wip"}, + {"name": "XPR6003SD5", "status": "wip"}, + {"name": "XPR603S", "status": "wip"}, + {"name": "XPR603SN", "status": "wip"}, + {"name": "XPR64000L", "status": "wip"}, + {"name": "XPR64001L", "status": "wip"}, + {"name": "XPR64002LC", "status": "wip"}, + {"name": "XPR64002LC-T", "status": "wip"}, + {"name": "XPR64003LD5C", "status": "wip"}, + {"name": "XPR6U", "status": "wip"}, + {"name": "XPR6U/M", "status": "wip"}, + {"name": "XPR6UD5", "status": "wip"}, + {"name": "XPR8001S", "status": "wip"}, + {"name": "XPR8002S", "status": "wip"}, + {"name": "XS10000M", "status": "wip"}, + {"name": "XS10001L", "status": "wip"}, + {"name": "XS10001M", "status": "wip"}, + {"name": "XS10001S", "status": "wip"}, + {"name": "XS10002S", "status": "wip"}, + {"name": "XS1003S", "status": "wip"}, + {"name": "XS104", "status": "wip"}, + {"name": "XS105", "status": "wip"}, + {"name": "XS1202S", "status": "wip"}, + {"name": "XS1203S", "status": "wip"}, + {"name": "XS16000L", "status": "wip"}, + {"name": "XS16000M", "status": "wip"}, + {"name": "XS16001L", "status": "wip"}, + {"name": "XS2002S", "status": "wip"}, + {"name": "XS203S", "status": "wip"}, + {"name": "XS204", "status": "wip"}, + {"name": "XS204SX", "status": "wip"}, + {"name": "XS205", "status": "wip"}, + {"name": "XS3", "status": "wip"}, + {"name": "XS303S", "status": "wip"}, + {"name": "XS303SN", "status": "wip"}, + {"name": "XS32000L", "status": "wip"}, + {"name": "XS32001L", "status": "wip"}, + {"name": "XS32001LDR", "status": "wip"}, + {"name": "XS32001LX", "status": "wip"}, + {"name": "XS4001S", "status": "wip"}, + {"name": "XS4002S", "status": "wip"}, + {"name": "XS403S", "status": "wip"}, + {"name": "XS5003S", "status": "wip"}, + {"name": "XS5003SXDR", "status": "wip"}, + {"name": "XS6001M", "status": "wip"}, + {"name": "XS6001MDR", "status": "wip"}, + {"name": "XS6001S", "status": "wip"}, + {"name": "XS6002S", "status": "wip"}, + {"name": "XS6002SDR", "status": "wip"}, + {"name": "XS6002SX", "status": "wip"}, + {"name": "XS603S", "status": "wip"}, + {"name": "XS603SN", "status": "wip"}, + {"name": "XS603SX", "status": "wip"}, + {"name": "XS64", "status": "wip"}, + {"name": "XS64001LX", "status": "wip"}, + {"name": "XS8001L", "status": "wip"}, + {"name": "XS8001S", "status": "wip"}, + {"name": "XS802S", "status": "wip"}, + {"name": "XSR10001L", "status": "wip"}, + {"name": "XSR10001S", "status": "wip"}, + {"name": "XSR10002S", "status": "wip"}, + {"name": "XSR104", "status": "wip"}, + {"name": "XSR105", "status": "wip"}, + {"name": "XSR105DU", "status": "wip"}, + {"name": "XSR1202S", "status": "wip"}, + {"name": "XSR1203S", "status": "wip"}, + {"name": "XSR16000L", "status": "wip"}, + {"name": "XSR16001L", "status": "wip"}, + {"name": "XSR2002S", "status": "wip"}, + {"name": "XSR204", "status": "wip"}, + {"name": "XSR204DR", "status": "wip"}, + {"name": "XSR205DU", "status": "wip"}, + {"name": "XSR225DU", "status": "wip"}, + {"name": "XSR303S", "status": "wip"}, + {"name": "XSR303SN", "status": "wip"}, + {"name": "XSR304", "status": "wip"}, + {"name": "XSR32000L", "status": "wip"}, + {"name": "XSR32001L", "status": "wip"}, + {"name": "XSR32001LDR", "status": "wip"}, + {"name": "XSR4001S", "status": "wip"}, + {"name": "XSR4002S", "status": "wip"}, + {"name": "XSR6001S", "status": "wip"}, + {"name": "XSR6002S", "status": "wip"}, + {"name": "XSR6002SDR", "status": "wip"}, + {"name": "XSR603S", "status": "wip"}, + {"name": "XSR603SN", "status": "wip"}, + {"name": "XSR64", "status": "wip"}, + {"name": "XSR8001S", "status": "wip"} + ], "kind": "scale", "capabilities": [ "weighing" ], - "status": "full", - "api": "pylabrobot.mettler_toledo.MettlerToledoWXS205SDU", + "status": "wip", + "api": "pylabrobot.mettler_toledo.MTSICSDriver", "api_version": "v1", - "code_slug": "mettler_toledo", - "doc_slug": "mettler_toledo/wxs205sdu/hello-world", + "code_slug": "mettler_toledo/scales", + "doc_slug": "mettler_toledo/scales/hello-world", "manager": "https://discuss.pylabrobot.org/u/rickwierenga", - "oem": "https://www.mt.com/us/en/home/products/Industrial_Weighing_Solutions/high-precision-weigh-sensors/weigh-module-wxs205sdu-15-11121008.html" + "notes": "The listed models are known to support SICS; only WXS205SDU/15 has been hardware-validated with this driver (WXA-Bridge firmware 1.10)." }, { "id": "micronic-code-reader", diff --git a/docs/api/pylabrobot.mettler_toledo.rst b/docs/api/pylabrobot.mettler_toledo.rst index 81c19d837a0..0574b6834dd 100644 --- a/docs/api/pylabrobot.mettler_toledo.rst +++ b/docs/api/pylabrobot.mettler_toledo.rst @@ -3,13 +3,13 @@ pylabrobot.mettler_toledo package ================================= -.. currentmodule:: pylabrobot.mettler_toledo.mettler_toledo +.. currentmodule:: pylabrobot.mettler_toledo .. autosummary:: :toctree: _autosummary :nosignatures: :recursive: - MettlerToledoWXS205SDU + MTSICSDriver MettlerToledoResponse MettlerToledoError diff --git a/docs/user_guide/mettler_toledo/index.md b/docs/user_guide/mettler_toledo/index.md index da3c481fa1e..94affcd0414 100644 --- a/docs/user_guide/mettler_toledo/index.md +++ b/docs/user_guide/mettler_toledo/index.md @@ -3,5 +3,5 @@ ```{toctree} :maxdepth: 1 -wxs205sdu/hello-world +scales/hello-world ``` diff --git a/docs/user_guide/mettler_toledo/scales/hello-world.ipynb b/docs/user_guide/mettler_toledo/scales/hello-world.ipynb new file mode 100644 index 00000000000..2911f146dc9 --- /dev/null +++ b/docs/user_guide/mettler_toledo/scales/hello-world.ipynb @@ -0,0 +1,473 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "overview", + "metadata": {}, + "source": [ + "# Mettler Toledo MT-SICS scales\n", + "\n", + "`MTSICSDriver` connects to Mettler Toledo scales and weigh modules that implement the\n", + "MT-SICS serial protocol. It discovers the commands advertised by the connected instrument\n", + "during setup, so one driver can support multiple models with different command sets.\n", + "\n", + "| Property | Value |\n", + "|---|---|\n", + "| Protocol | MT-SICS (Mettler Toledo Standard Interface Command Set) |\n", + "| Communication | RS-232 serial |\n", + "| Driver default baud rate | 9600 |\n", + "| Default USB adapter match | `0x0403:0x6001` (FTDI FT232R) |\n", + "| PyLabRobot API | `MTSICSDriver` |\n", + "| Hardware-validated example | [WXS205SDU/15](https://www.mt.com/us/en/home/products/Industrial_Weighing_Solutions/high-precision-weigh-sensors/weigh-module-wxs205sdu-15-11121008.html), reported by the instrument as WXS205SDU WXA-Bridge, firmware 1.10 |\n", + "\n", + "Support is based on the commands reported by the instrument, not on a hard-coded model name.\n", + "The WXS205SDU/15 is the currently hardware-validated example; other MT-SICS models may expose a\n", + "different subset of the methods shown below.\n" + ] + }, + { + "cell_type": "markdown", + "id": "device-card", + "metadata": {}, + "source": [ + "```{device-card} mettler-toledo-mt-sics\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "communication", + "metadata": {}, + "source": [ + "## How it communicates\n", + "\n", + "MT-SICS (Mettler Toledo Standard Interface Command Set) is an ASCII request/response\n", + "protocol. Commands and responses are terminated by carriage return and line feed. The driver\n", + "communicates through PyLabRobot's serial transport and handles framing, response parsing,\n", + "multi-line responses, and MT-SICS error codes.\n", + "\n", + "During `setup()`, the driver resets the interface, queries `I0` to discover supported\n", + "commands, reads the device identity and firmware, and selects grams as the host unit when the\n", + "instrument supports that setting. A method whose MT-SICS command is unavailable on the\n", + "connected model raises `MettlerToledoError` before sending it." + ] + }, + { + "cell_type": "markdown", + "id": "physical-setup", + "metadata": {}, + "source": [ + "## Physical setup\n", + "\n", + "Hardware layouts and connectors vary by model. Some systems use a separate load cell,\n", + "electronic unit, and terminal, while others integrate these components. Follow the manual for\n", + "your instrument, then connect its MT-SICS-capable RS-232 interface to the computer. A\n", + "USB-to-serial adapter is normally required.\n", + "\n", + "The driver defaults to the FTDI FT232R VID:PID `0x0403:0x6001`. If your adapter uses different\n", + "identifiers, pass the serial port explicitly or provide its `vid` and `pid` when creating the\n", + "driver.\n", + "\n", + "Install PyLabRobot with serial support before continuing:\n", + "\n", + "```bash\n", + "pip install \"pylabrobot[serial]\"\n", + "```\n", + "\n", + "```{warning}\n", + "Place the scale on a stable, level surface and follow the warm-up and environmental guidance\n", + "for your model before measuring. An instrument that is not ready may report that a command is\n", + "understood but not currently executable.\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "connect", + "metadata": {}, + "source": [ + "## Connect\n", + "\n", + "Create the driver with the serial port used by the scale and call `setup()`. Port names are\n", + "typically `/dev/ttyUSB0` on Linux, `/dev/cu.usbserial-*` on macOS, and `COM3` or similar on\n", + "Windows. If `port` is omitted, the driver searches for the configured USB VID and PID.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "connect-code", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.mettler_toledo import MTSICSDriver\n", + "\n", + "scale = MTSICSDriver(port=\"/dev/cu.usbserial-110\") # replace with your serial port\n", + "await scale.setup()" + ] + }, + { + "cell_type": "markdown", + "id": "discovered-device", + "metadata": {}, + "source": [ + "### Confirm the discovered instrument\n", + "\n", + "`setup()` records the identity and capacity reported by the instrument. Inspect these values\n", + "before starting a measurement workflow, especially when several serial devices are connected.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "discovered-device-code", + "metadata": {}, + "outputs": [], + "source": [ + "print(f\"Model: {scale.device_type}\")\n", + "print(f\"Serial number: {scale.serial_number}\")\n", + "print(f\"Firmware: {scale.firmware_version}\")\n", + "print(f\"Capacity: {scale.capacity} g\")" + ] + }, + { + "cell_type": "markdown", + "id": "zero", + "metadata": {}, + "source": [ + "## Zero the empty scale\n", + "\n", + "Remove everything from the weighing platform, then call `zero()`. The default waits for a\n", + "stable reading. Use `zero(timeout=0)` only when an immediate zero is preferable to waiting for\n", + "stability." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "zero-code", + "metadata": {}, + "outputs": [], + "source": [ + "await scale.zero()" + ] + }, + { + "cell_type": "markdown", + "id": "tare", + "metadata": {}, + "source": [ + "## Tare a container\n", + "\n", + "Place the empty container on the platform and wait for the reading to settle. `tare()` stores\n", + "its weight so subsequent readings report only the sample's net weight." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "tare-code", + "metadata": {}, + "outputs": [], + "source": [ + "await scale.tare()" + ] + }, + { + "cell_type": "markdown", + "id": "read-weight", + "metadata": {}, + "source": [ + "## Read a stable weight\n", + "\n", + "Add the sample to the tared container. `read_weight()` waits for stability and returns the\n", + "weight in grams as a `float`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "read-weight-code", + "metadata": {}, + "outputs": [], + "source": [ + "weight_g = await scale.read_weight()\n", + "print(f\"Weight: {weight_g:.4f} g\")" + ] + }, + { + "cell_type": "markdown", + "id": "read-immediate", + "metadata": {}, + "source": [ + "### Read immediately\n", + "\n", + "Use `timeout=0` when the current value is needed even if it is still changing. The result is\n", + "still expressed in grams." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "read-immediate-code", + "metadata": {}, + "outputs": [], + "source": [ + "current_weight_g = await scale.read_weight(timeout=0)\n", + "print(f\"Current weight: {current_weight_g:.4f} g\")" + ] + }, + { + "cell_type": "markdown", + "id": "tare-value", + "metadata": {}, + "source": [ + "### Inspect the stored tare\n", + "\n", + "Query the tare value currently stored by the scale." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "tare-value-code", + "metadata": {}, + "outputs": [], + "source": [ + "tare_weight_g = await scale.request_tare_weight()\n", + "print(f\"Stored tare: {tare_weight_g:.4f} g\")" + ] + }, + { + "cell_type": "markdown", + "id": "clear-tare", + "metadata": {}, + "source": [ + "### Clear the tare\n", + "\n", + "Remove the container, then clear the stored tare when the workflow is finished." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "clear-tare-code", + "metadata": {}, + "outputs": [], + "source": [ + "await scale.clear_tare()" + ] + }, + { + "cell_type": "markdown", + "id": "temperature", + "metadata": {}, + "source": [ + "## Measure the internal temperature\n", + "\n", + "Some MT-SICS instruments, including the hardware-validated WXS205SDU/15, expose an internal\n", + "temperature sensor with the `M28` command. This can be useful when temperature affects density\n", + "calculations in gravimetric verification. Skip this call if your model does not advertise\n", + "`M28`; the driver will otherwise raise `MettlerToledoError`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "temperature-code", + "metadata": {}, + "outputs": [], + "source": [ + "temperature_c = await scale.measure_temperature()\n", + "print(f\"Internal temperature: {temperature_c:.1f} °C\")" + ] + }, + { + "cell_type": "markdown", + "id": "identity-queries", + "metadata": {}, + "source": [ + "## Query device identity\n", + "\n", + "Identity methods can be called again after setup when a workflow needs to record instrument\n", + "provenance alongside its measurements. The additional identity fields below are model\n", + "dependent; an unsupported command raises `MettlerToledoError`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "identity-queries-code", + "metadata": {}, + "outputs": [], + "source": [ + "identity = {\n", + " \"serial_number\": await scale.request_serial_number(),\n", + " \"device_type\": await scale.request_device_type(),\n", + " \"model\": await scale.request_model_designation(),\n", + " \"firmware\": await scale.request_firmware_version(),\n", + " \"software_material_number\": await scale.request_software_material_number(),\n", + "}\n", + "identity" + ] + }, + { + "cell_type": "markdown", + "id": "device-status", + "metadata": {}, + "source": [ + "## Query device status\n", + "\n", + "The following read-only calls report the instrument's clock, uptime, and next configured\n", + "service date when their corresponding commands are supported by the connected model.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "device-status-code", + "metadata": {}, + "outputs": [], + "source": [ + "status = {\n", + " \"date\": await scale.request_date(),\n", + " \"time\": await scale.request_time(),\n", + " \"uptime_minutes\": await scale.request_uptime_minutes(),\n", + " \"next_service_date\": await scale.request_next_service_date(),\n", + "}\n", + "status" + ] + }, + { + "cell_type": "markdown", + "id": "weight-status", + "metadata": {}, + "source": [ + "## Read weight with MT-SICS status\n", + "\n", + "`request_net_weight_with_status()` exposes the structured MT-SICS response when a workflow\n", + "needs the stability state, unit code, readability, approval state, or tare information in\n", + "addition to the numeric weight." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "weight-status-code", + "metadata": {}, + "outputs": [], + "source": [ + "response = await scale.request_net_weight_with_status()\n", + "print(f\"Command: {response.command}\")\n", + "print(f\"Status: {response.status}\")\n", + "print(f\"Data: {response.data}\")" + ] + }, + { + "cell_type": "markdown", + "id": "detailed-info", + "metadata": {}, + "source": [ + "## Read a multi-line response\n", + "\n", + "Some MT-SICS commands return several lines. The driver returns them as a list of\n", + "`MettlerToledoResponse` objects. For example, instruments that implement `I14` can report\n", + "installed components through device-information category 0.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "detailed-info-code", + "metadata": {}, + "outputs": [], + "source": [ + "device_info = await scale.request_device_info(category=0)\n", + "for line in device_info:\n", + " print(line.command, line.status, line.data)" + ] + }, + { + "cell_type": "markdown", + "id": "configuration", + "metadata": {}, + "source": [ + "## Inspect weighing configuration\n", + "\n", + "These configuration-query methods are read-only and model dependent. Their returned values are\n", + "MT-SICS setting codes; consult the Mettler Toledo MT-SICS reference for the meaning of each code\n", + "on your model.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "configuration-code", + "metadata": {}, + "outputs": [], + "source": [ + "configuration = {\n", + " \"weighing_mode\": await scale.request_weighing_mode(),\n", + " \"environment_condition\": await scale.request_environment_condition(),\n", + " \"auto_zero\": await scale.request_auto_zero(),\n", + " \"update_rate_hz\": await scale.request_update_rate(),\n", + "}\n", + "configuration" + ] + }, + { + "cell_type": "markdown", + "id": "model-differences", + "metadata": {}, + "source": [ + "## Command availability across models\n", + "\n", + "Not every MT-SICS instrument implements every command. During `setup()`, the driver queries\n", + "`I0` and records the commands advertised by the connected instrument. Calling a method whose\n", + "command was not advertised raises `MettlerToledoError` before anything is sent.\n", + "\n", + "For example, the WXS205SDU/15 WXA-Bridge used for hardware validation does not expose the timed\n", + "zero/tare/read commands, display commands, the cancel-all command, or remaining-range query.\n", + "Those methods remain available for other MT-SICS models that advertise the corresponding\n", + "commands.\n", + "\n", + "Methods such as `set_device_id()`, `set_date()`, and `set_time()` intentionally change\n", + "persistent device state and are therefore not run in this hello-world guide.\n" + ] + }, + { + "cell_type": "markdown", + "id": "disconnect", + "metadata": {}, + "source": [ + "## Disconnect\n", + "\n", + "Always stop the driver when the workflow finishes. `stop()` attempts to reset the interface\n", + "to a determined state before closing the serial connection." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "disconnect-code", + "metadata": {}, + "outputs": [], + "source": [ + "await scale.stop()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/mettler_toledo/wxs205sdu/hello-world.ipynb b/docs/user_guide/mettler_toledo/wxs205sdu/hello-world.ipynb deleted file mode 100644 index ce9d0020532..00000000000 --- a/docs/user_guide/mettler_toledo/wxs205sdu/hello-world.ipynb +++ /dev/null @@ -1,196 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "5pmhklv4dll", - "metadata": {}, - "source": [ - "# Mettler Toledo WXS205SDU\n", - "\n", - "The WXS205SDU is a high-precision automated weigh module from Mettler Toledo, commonly used for gravimetric liquid transfer verification (e.g. in the Hamilton Liquid Verification Kit).\n", - "\n", - "| Property | Value |\n", - "|---|---|\n", - "| [OEM Link](https://www.mt.com/gb/en/home/products/Industrial_Weighing_Solutions/high-precision-weigh-sensors/weigh-module-wxs205sdu-15-11121008.html) | |\n", - "| Communication | Serial / RS-232 |\n", - "| VID:PID | `0x0403:0x6001` |\n", - "| Load range | 0 -- 220 g |\n", - "| Readability | 0.1 mg |\n", - "\n", - "The backend has been tested on the WXS205SDU but, per Mettler Toledo firmware documentation, should be applicable to other \"Automated Precision Weigh Modules\" in the WX and WMS series." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "```{device-card} mettler-toledo-wxs205sdu\n", - "```" - ], - "id": "device-card" - }, - { - "cell_type": "markdown", - "id": "8v1qlztfpn", - "metadata": {}, - "source": [ - "## Physical setup\n", - "\n", - "The system consists of two required units and one optional unit:\n", - "\n", - "| Component | Required | Description |\n", - "|---|---|---|\n", - "| Load Cell | yes | The weighing platform where samples are placed |\n", - "| Electronic Unit | yes | The control and communication module |\n", - "| Terminal/Display | no | For manual reading; not needed when using PyLabRobot |\n", - "\n", - "Connect the electronic unit to your computer via the RS-232 serial port. You will likely need a USB-to-serial adapter (any generic FTDI-based adapter should work).\n", - "\n", - "```{warning}\n", - "The scale requires a warm-up period after being powered on. Mettler Toledo specifies 60--90 minutes, though 30 minutes is often sufficient in practice. If you attempt measurements before warm-up, you may see: *\"Command understood but currently not executable\"*.\n", - "```" - ] - }, - { - "cell_type": "markdown", - "id": "1mzumf8u2bu", - "metadata": {}, - "source": [ - "## Setup" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "djeqvngxd3c", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.mettler_toledo import MettlerToledoWXS205SDU\n", - "\n", - "scale = MettlerToledoWXS205SDU(port=\"/dev/cu.usbserial-110\") # replace with your port\n", - "await scale.setup()" - ] - }, - { - "cell_type": "markdown", - "id": "opa273q0bvc", - "metadata": {}, - "source": [ - "## Weighing\n", - "\n", - "The scale exposes `zero()`, `tare()`, and `read_weight()`.\n", - "\n", - "### Zero\n", - "\n", - "Calibrates the scale to read zero with an empty platform. Use at the start of a workflow." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "dqc8j70q1m", - "metadata": {}, - "outputs": [], - "source": [ - "await scale.zero()" - ] - }, - { - "cell_type": "markdown", - "id": "jtaypw6mx8", - "metadata": {}, - "source": [ - "### Tare\n", - "\n", - "Resets the displayed weight to zero while accounting for a container already on the platform. Place your container, then tare, so subsequent readings reflect only the added material." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "k0b0bx9qaqq", - "metadata": {}, - "outputs": [], - "source": [ - "await scale.tare()" - ] - }, - { - "cell_type": "markdown", - "id": "i8ewtl6bitj", - "metadata": {}, - "source": [ - "### Read weight\n", - "\n", - "Returns the current weight in grams." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "77so0rlhk39", - "metadata": {}, - "outputs": [], - "source": [ - "weight = await scale.read_weight()\n", - "print(f\"Weight: {weight} g\")" - ] - }, - { - "cell_type": "markdown", - "id": "p4p4hpeg11", - "metadata": {}, - "source": [ - "### Further methods\n", - "\n", - "- `request_tare_weight()` -- retrieve the stored tare value\n", - "- `request_serial_number()` -- read the scale's serial number\n", - "- `clear_tare()` -- clear the stored tare weight\n", - "- `zero(timeout=...)` / `tare(timeout=...)` / `read_weight(timeout=...)` -- pass a timeout mode (`\"stable\"`, `0`, or seconds)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7ha7yrkc069", - "metadata": {}, - "outputs": [], - "source": [ - "tare_value = await scale.request_tare_weight()\n", - "print(f\"Stored tare: {tare_value} g\")" - ] - }, - { - "cell_type": "markdown", - "id": "oq3ck6rntr", - "metadata": {}, - "source": [ - "## Teardown" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "q7m594v11eb", - "metadata": {}, - "outputs": [], - "source": [ - "await scale.stop()" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "name": "python", - "version": "3.11.0" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/pylabrobot/mettler_toledo/__init__.py b/pylabrobot/mettler_toledo/__init__.py index f1935d0ea7b..9a71c972d0b 100644 --- a/pylabrobot/mettler_toledo/__init__.py +++ b/pylabrobot/mettler_toledo/__init__.py @@ -1 +1,13 @@ -from .mettler_toledo import MettlerToledoError, MettlerToledoWXS205SDU +from .scales import ( + MettlerToledoError, + MettlerToledoResponse, + MettlerToledoWXS205SDU, + MTSICSDriver, +) + +__all__ = [ + "MTSICSDriver", + "MettlerToledoError", + "MettlerToledoResponse", + "MettlerToledoWXS205SDU", +] diff --git a/pylabrobot/mettler_toledo/mettler_toledo.py b/pylabrobot/mettler_toledo/mettler_toledo.py deleted file mode 100644 index 2fe5a8b430d..00000000000 --- a/pylabrobot/mettler_toledo/mettler_toledo.py +++ /dev/null @@ -1,469 +0,0 @@ -# similar library: https://github.com/janelia-pypi/mettler_toledo_device_python - -import asyncio -import logging -import time -from typing import List, Literal, Optional, Union - -from pylabrobot.io.serial import Serial - -logger = logging.getLogger(__name__) - - -class MettlerToledoError(Exception): - """Exceptions raised by a Mettler Toledo scale.""" - - def __init__(self, title: str, message: Optional[str] = None) -> None: - self.title = title - self.message = message - - def __str__(self) -> str: - return f"{self.title}: {self.message}" - - @staticmethod - def unknown_error() -> "MettlerToledoError": - return MettlerToledoError(title="Unknown error", message="An unknown error occurred") - - @staticmethod - def executing_another_command() -> "MettlerToledoError": - return MettlerToledoError( - title="Command not understood, not executable at present", - message=( - "Command understood but currently not executable (balance is " - "currently executing another command)." - ), - ) - - @staticmethod - def incorrect_parameter() -> "MettlerToledoError": - return MettlerToledoError( - title="Command understood but not executable", - message="(incorrect parameter).", - ) - - @staticmethod - def overload() -> "MettlerToledoError": - return MettlerToledoError(title="Balance in overload range.", message=None) - - @staticmethod - def underload() -> "MettlerToledoError": - return MettlerToledoError(title="Balance in underload range.", message=None) - - @staticmethod - def syntax_error() -> "MettlerToledoError": - return MettlerToledoError( - title="Syntax error", - message="The weigh module/balance has not recognized the received command or the command is " - "not allowed", - ) - - @staticmethod - def transmission_error() -> "MettlerToledoError": - return MettlerToledoError( - title="Transmission error", - message="The weigh module/balance has received a 'faulty' command, e.g. owing to a parity " - "error or interface break", - ) - - @staticmethod - def logical_error() -> "MettlerToledoError": - return MettlerToledoError( - title="Logical error", - message="The weigh module/balance can not execute the received command", - ) - - @staticmethod - def boot_error(from_terminal: bool) -> "MettlerToledoError": - return MettlerToledoError( - title="Boot error", - message="from terminal" if from_terminal else "from electronics", - ) - - @staticmethod - def brand_error(from_terminal: bool) -> "MettlerToledoError": - return MettlerToledoError( - title="Brand error", - message="from terminal" if from_terminal else "from electronics", - ) - - @staticmethod - def checksum_error(from_terminal: bool) -> "MettlerToledoError": - return MettlerToledoError( - title="Checksum error", - message="from terminal" if from_terminal else "from electronics", - ) - - @staticmethod - def option_fail(from_terminal: bool) -> "MettlerToledoError": - return MettlerToledoError( - title="Option fail", - message="from terminal" if from_terminal else "from electronics", - ) - - @staticmethod - def eeprom_error(from_terminal: bool) -> "MettlerToledoError": - return MettlerToledoError( - title="EEPROM error", - message="from terminal" if from_terminal else "from electronics", - ) - - @staticmethod - def device_mismatch(from_terminal: bool) -> "MettlerToledoError": - return MettlerToledoError( - title="Device mismatch", - message="from terminal" if from_terminal else "from electronics", - ) - - @staticmethod - def hot_plug_out(from_terminal: bool) -> "MettlerToledoError": - return MettlerToledoError( - title="Hot plug out", - message="from terminal" if from_terminal else "from electronics", - ) - - @staticmethod - def weight_module_electronic_mismatch( - from_terminal: bool, - ) -> "MettlerToledoError": - return MettlerToledoError( - title="Weight module / electronic mismatch", - message="from terminal" if from_terminal else "from electronics", - ) - - @staticmethod - def adjustment_needed(from_terminal: bool) -> "MettlerToledoError": - return MettlerToledoError( - title="Adjustment needed", - message="from terminal" if from_terminal else "from electronics", - ) - - -MettlerToledoResponse = List[str] - - -class MettlerToledoWXS205SDU: - """Driver for the Mettler Toledo WXS205SDU scale. - - Owns the serial connection and provides a generic send_command method. - Device-level operations (display) live here. - - Documentation: https://web.archive.org/web/20240208213802/https://www.mt.com/dam/ - product_organizations/industry/apw/generic/11781363_N_MAN_RM_MT-SICS_APW_en.pdf - - From the docs: - - "If several commands are sent in succession without waiting for the corresponding - responses, it is possible that the weigh module/balance confuses the sequence of - command processing or ignores entire commands." - """ - - def __init__(self, port: Optional[str] = None, vid: int = 0x0403, pid: int = 0x6001): - super().__init__() - self.io = Serial( - human_readable_device_name="Mettler Toledo WXS205SDU", - port=port, - vid=vid, - pid=pid, - baudrate=9600, - timeout=1, - ) - - async def setup(self) -> None: - await self.io.setup() - logger.info("[MettlerToledo %s] connected", self.io.port) - - await self.send_command("M21 0 0") - self.serial_number = await self.request_serial_number() - logger.info( - "[MettlerToledo %s] initialized: serial_number=%s", self.io.port, self.serial_number - ) - - async def stop(self) -> None: - await self.io.stop() - - # === Response parsing === - - def _parse_basic_errors(self, response: List[str]) -> None: - """Helper function for parsing basic errors that are common to many commands. If an error is - detected, a 'MettlerToledoError' exception is raised. - - These are in the first place of the response: - - ES: syntax error: The weigh module/balance has not recognized the received command or the - command is not allowed - - ET: transmission error: The weigh module/balance has received a "faulty" command, e.g. owing - to a parity error or interface break - - EL: logical error: The weigh module/balance can not execute the received command - - These are in the second place of the response (MT-SICS spec p.10, sec 2.1.3.1): - - A: Command executed successfully - - B: Command not yet terminated, additional responses following - - I: Internal error (e.g. balance not ready yet) - - L: Logical error (e.g. parameter not allowed) - - +: Balance in overload range - - -: Balance in underload range - - TODO: handle 'B' status — multi-response commands (e.g. C1 adjustment) send 'B' first, - then additional responses, then 'A' on completion. Currently send_command returns after - the first response, so 'B' responses are not followed up. - """ - - if response[0] == "ES": - raise MettlerToledoError.syntax_error() - if response[0] == "ET": - raise MettlerToledoError.transmission_error() - if response[0] == "EL": - raise MettlerToledoError.logical_error() - - if response[1] == "I": - raise MettlerToledoError.executing_another_command() - if response[1] == "L": - raise MettlerToledoError.incorrect_parameter() - if response[1] == "+": - raise MettlerToledoError.overload() - if response[1] == "-": - raise MettlerToledoError.underload() - - if response[0] == "S" and response[1] == "S" and response[2] == "Error": - error_code = response[3] - code, source = error_code[:-1], error_code[-1] - from_terminal = source == "t" - if code == "1": - raise MettlerToledoError.boot_error(from_terminal=from_terminal) - if code == "2": - raise MettlerToledoError.brand_error(from_terminal=from_terminal) - if code == "3": - raise MettlerToledoError.checksum_error(from_terminal=from_terminal) - if code == "9": - raise MettlerToledoError.option_fail(from_terminal=from_terminal) - if code == "10": - raise MettlerToledoError.eeprom_error(from_terminal=from_terminal) - if code == "11": - raise MettlerToledoError.device_mismatch(from_terminal=from_terminal) - if code == "12": - raise MettlerToledoError.hot_plug_out(from_terminal=from_terminal) - if code == "14": - raise MettlerToledoError.weight_module_electronic_mismatch(from_terminal=from_terminal) - if code == "15": - raise MettlerToledoError.adjustment_needed(from_terminal=from_terminal) - - # === Command Layer === - - async def send_command(self, command: str, timeout: int = 60) -> MettlerToledoResponse: - """Send a command to the scale and receive the response. - - Args: - timeout: The timeout in seconds. - """ - - await self.io.write(command.encode() + b"\r\n") - - raw_response = b"" - timeout_time = time.time() + timeout - while True: - raw_response = await self.io.readline() - await asyncio.sleep(0.001) - if time.time() > timeout_time: - raise TimeoutError("Timeout while waiting for response from scale.") - if raw_response != b"": - break - logger.debug("[scale] Received response: %s", raw_response) - response = raw_response.decode("utf-8").strip().split() - - # parse basic errors - self._parse_basic_errors(response) - - # mypy doesn't understand this - return response # type: ignore - - # === Device-level operations === - - async def set_display_text(self, text: str) -> MettlerToledoResponse: - """Set the display text of the scale. Return to the normal weight display with - self.set_weight_display().""" - return await self.send_command(f'D "{text}"') - - async def set_weight_display(self) -> MettlerToledoResponse: - """Return the display to the normal weight display.""" - return await self.send_command("DW") - - # === Public high-level API === - - async def request_serial_number(self) -> str: - """Get the serial number of the scale. (MEM-READ command)""" - response = await self.send_command("I4") - serial_number = response[2] - serial_number = serial_number.replace('"', "") - return serial_number - - # # Zero commands # # - - async def zero_immediately(self) -> MettlerToledoResponse: - """Zero the scale immediately. (ACTION command)""" - return await self.send_command("ZI") - - async def zero_stable(self) -> MettlerToledoResponse: - """Zero the scale when the weight is stable. (ACTION command)""" - return await self.send_command("Z") - - async def zero_timeout(self, timeout: float) -> MettlerToledoResponse: - """Zero the scale after a given timeout. (ACTION command)""" - # For some reason, this will always return a syntax error (ES), even though it should be allowed - # according to the docs. - timeout = int(timeout * 1000) - return await self.send_command(f"ZC {timeout}") - - async def zero( - self, timeout: Union[Literal["stable"], float, int] = "stable" - ) -> MettlerToledoResponse: - """High level function to zero the scale. (ACTION command) - - Args: - timeout: The timeout in seconds. If "stable", the scale will zero when the weight is stable. - If 0, the scale will zero immediately. If a float/int, the scale will zero after the given - timeout (in seconds). - """ - - if timeout == "stable": - result = await self.zero_stable() - elif not isinstance(timeout, (float, int)): - raise TypeError("timeout must be a float or 'stable'") - elif timeout < 0: - raise ValueError("timeout must be greater than or equal to 0") - elif timeout == 0: - result = await self.zero_immediately() - else: - result = await self.zero_timeout(timeout) - - logger.info("[MettlerToledo %s] zeroed: timeout=%s", self.serial_number, timeout) - return result - - # # Tare commands # # - - async def tare_stable(self) -> MettlerToledoResponse: - """Tare the scale when the weight is stable. (ACTION command)""" - return await self.send_command("T") - - async def tare_immediately(self) -> MettlerToledoResponse: - """Tare the scale immediately. (ACTION command)""" - return await self.send_command("TI") - - async def tare_timeout(self, timeout: float) -> MettlerToledoResponse: - """Tare the scale after a given timeout. (ACTION command)""" - # For some reason, this will always return a syntax error (ES), even though it should be allowed - # according to the docs. - timeout = int(timeout * 1000) # convert to milliseconds - return await self.send_command(f"TC {timeout}") - - async def tare( - self, timeout: Union[Literal["stable"], float, int] = "stable" - ) -> MettlerToledoResponse: - """High level function to tare the scale. (ACTION command) - - Args: - timeout: The timeout in seconds. If "stable", the scale will tare when the weight is stable. - If 0, the scale will tare immediately. If a float/int, the scale will tare after the given - timeout (in seconds). - """ - - if timeout == "stable": - # "Use T to tare the balance. The next stable weight value will be saved in the tare memory." - result = await self.tare_stable() - elif not isinstance(timeout, (float, int)): - raise TypeError("timeout must be a float or 'stable'") - elif timeout < 0: - raise ValueError("timeout must be greater than or equal to 0") - elif timeout == 0: - result = await self.tare_immediately() - else: - result = await self.tare_timeout(timeout) - - logger.info("[MettlerToledo %s] tared: timeout=%s", self.serial_number, timeout) - return result - - # # Weight reading commands # # - - async def request_tare_weight(self) -> float: - """Request tare weight value from scale's memory. (MEM-READ command) - "Use TA to query the current tare value or preset a known tare value." - """ - - response = await self.send_command("TA") - tare = float(response[2]) - unit = response[3] - assert unit == "g" # this is the format we expect - return tare - - async def clear_tare(self) -> MettlerToledoResponse: - """TAC - Clear tare weight value (MEM-WRITE command)""" - return await self.send_command("TAC") - - async def read_stable_weight(self) -> float: - """Read a stable weight value from the scale. (MEASUREMENT command) - - from the docs: - - "Use S to send a stable weight value, along with the host unit, from the balance to - the connected communication partner via the interface. If the automatic door function - is enabled and a stable weight is requested the balance will open and close the balance's - doors to achieve a stable weight." - """ - - response = await self.send_command("S") - weight = float(response[2]) - unit = response[3] - assert unit == "g" # this is the format we expect - logger.info("[MettlerToledo %s] stable weight read: weight_g=%s", self.serial_number, weight) - return weight - - async def read_dynamic_weight(self, timeout: float) -> float: - """Read a stable weight value from the machine within a given timeout, or - return the current weight value if not possible. (MEASUREMENT command) - - Args: - timeout: The timeout in seconds. - """ - - timeout = int(timeout * 1000) # convert to milliseconds - - response = await self.send_command(f"SC {timeout}") - weight = float(response[2]) - unit = response[3] - assert unit == "g" # this is the format we expect - logger.info("[MettlerToledo %s] dynamic weight read: weight_g=%s", self.serial_number, weight) - return weight - - async def read_weight_value_immediately(self) -> float: - """Read a weight value immediately from the scale. (MEASUREMENT command) - - "Use SI to immediately send the current weight value, along with the host unit, from the - balance to the connected communication partner via the interface." - """ - - response = await self.send_command("SI") - weight = float(response[2]) - assert response[3] == "g" # this is the format we expect - logger.info("[MettlerToledo %s] immediate weight read: weight_g=%s", self.serial_number, weight) - return weight - - async def read_weight(self, timeout: Union[Literal["stable"], float, int] = "stable") -> float: - """High level function to read a weight value from the scale. (MEASUREMENT command) - - Args: - timeout: The timeout in seconds. If "stable", the scale will return a weight value when the - weight is stable. If 0, the scale will return a weight value immediately. If a float/int, - the scale will return a weight value after the given timeout (in seconds). - """ - - if timeout == "stable": - return await self.read_stable_weight() - - if not isinstance(timeout, (float, int)): - raise TypeError("timeout must be a float or 'stable'") - - if timeout < 0: - raise ValueError("timeout must be greater than or equal to 0") - - if timeout == 0: - return await self.read_weight_value_immediately() - - return await self.read_dynamic_weight(timeout) diff --git a/pylabrobot/mettler_toledo/scales/__init__.py b/pylabrobot/mettler_toledo/scales/__init__.py new file mode 100644 index 00000000000..7fb4da751c0 --- /dev/null +++ b/pylabrobot/mettler_toledo/scales/__init__.py @@ -0,0 +1,13 @@ +"""Mettler Toledo scale drivers using the MT-SICS protocol.""" + +from .driver import MettlerToledoResponse, MTSICSDriver +from .errors import MettlerToledoError + +MettlerToledoWXS205SDU = MTSICSDriver + +__all__ = [ + "MTSICSDriver", + "MettlerToledoError", + "MettlerToledoResponse", + "MettlerToledoWXS205SDU", +] diff --git a/pylabrobot/mettler_toledo/scales/confirmed_firmware_versions.py b/pylabrobot/mettler_toledo/scales/confirmed_firmware_versions.py new file mode 100644 index 00000000000..a34f346e35c --- /dev/null +++ b/pylabrobot/mettler_toledo/scales/confirmed_firmware_versions.py @@ -0,0 +1,16 @@ +"""Firmware versions confirmed to work with this driver. + +The firmware version is queried via the I3 command (request_firmware_version) during setup(). +If the connected device runs a version not in this list, a warning +is logged. Please report untested versions that work so they can +be added. + +Only the major.minor version is checked (e.g. "1.10"), not the full +I3 response string (e.g. "1.10 18.6.4.1361.772"), because the second +part is a type definition number that varies by hardware revision and +model while the firmware behavior is determined by the version number. +""" + +CONFIRMED_FIRMWARE_VERSIONS = [ + "1.10", +] diff --git a/pylabrobot/mettler_toledo/scales/driver.py b/pylabrobot/mettler_toledo/scales/driver.py new file mode 100644 index 00000000000..8eef41fcebe --- /dev/null +++ b/pylabrobot/mettler_toledo/scales/driver.py @@ -0,0 +1,986 @@ +"""Mettler Toledo scale driver using the MT-SICS serial protocol.""" + +# similar library: https://github.com/janelia-pypi/mettler_toledo_device_python + +import asyncio +import logging +import shlex +import time +from dataclasses import dataclass, field +from typing import List, Literal, Optional, Set, Union + +from pylabrobot.io.serial import Serial +from pylabrobot.io.validation_utils import LOG_LEVEL_IO + +from .confirmed_firmware_versions import CONFIRMED_FIRMWARE_VERSIONS +from .errors import MettlerToledoError + +logger = logging.getLogger(__name__) + + +@dataclass +class MettlerToledoResponse: + """A single parsed MT-SICS response line. + + Format: [ ...] CR LF + See protocol.md for full format description. + """ + + command: str + status: str + data: List[str] = field(default_factory=list) + + +class MTSICSDriver: + """Driver for Mettler Toledo scales using the MT-SICS protocol. + + MT-SICS (Mettler Toledo Standard Interface Command Set) is the serial communication + protocol used by Mettler Toledo's Automated Precision Weigh Modules. This driver is + compatible with any MT-SICS device, including the WXS, WMS, and WX series. + + During setup(), the driver queries I0 to discover which commands the connected device + supports, then queries the device identity and firmware. ``send_command`` will raise + ``MettlerToledoError`` if the command is not in the device's I0 command list. + + Tested on the WXS205SDU (used by Hamilton in the Liquid Verification Kit). + + Spec: https://web.archive.org/web/20240208213802/https://www.mt.com/dam/ + product_organizations/industry/apw/generic/11781363_N_MAN_RM_MT-SICS_APW_en.pdf + + From the spec (Section 2.2): + "If several commands are sent in succession without waiting for the corresponding + responses, it is possible that the weigh module/balance confuses the sequence of + command processing or ignores entire commands." + """ + + # === Constructor === + + def __init__(self, port: Optional[str] = None, vid: int = 0x0403, pid: int = 0x6001): + """Create a new MT-SICS driver. + + Args: + port: Serial port path. If None, auto-detected by VID:PID. + vid: USB vendor ID (default 0x0403 = FTDI). + pid: USB product ID (default 0x6001 = FT232R). + """ + self._supported_commands: Set[str] = set() + + self.io = Serial( + human_readable_device_name="Mettler Toledo Scale", + port=port, + vid=vid, + pid=pid, + baudrate=9600, + timeout=1, + ) + + async def setup(self) -> None: + """Connect to the scale, reset to clean state, discover identity and supported commands.""" + await self.io.setup() + + # Reset device to clean state (spec Section 2.2) + # reset() clears the input buffer and sends @, which returns the serial number + self.serial_number = await self.reset() + + # Discover supported commands via I0 (the definitive source per spec Section 2.2) + self._supported_commands = await self._request_supported_commands() + + # Device identity (Level 0 - always available) + # Note: device_type and capacity both use I2 but are separate methods intentionally - + # single-responsibility per method, the duplicate I2 round-trip during one-time setup is fine. + self.device_type = await self.request_device_type() + self.capacity = await self.request_capacity() + + # Firmware version and configuration + self.firmware_version = await self.request_firmware_version() + # I2 device_type encodes the configuration: "WXS205SDU WXA-Bridge" = bridge only + self.configuration = "Bridge" if "Bridge" in self.device_type else "Balance" + + logger.info( + "[%s] Connected on %s\n" + "Device type: %s\n" + "Configuration: %s\n" + "Serial number: %s\n" + "Firmware: %s\n" + "Capacity: %.1f g\n" + "Supported commands (%d): %s", + self.io.human_readable_device_name, + self.io.port, + self.device_type, + self.configuration, + self.serial_number, + self.firmware_version, + self.capacity, + len(self._supported_commands), + ", ".join(sorted(self._supported_commands)), + ) + + # Check major.minor version only (TDNR varies by hardware revision) + fw_version_short = self.firmware_version.split()[0] if self.firmware_version else "" + if fw_version_short not in CONFIRMED_FIRMWARE_VERSIONS: + logger.warning( + "[%s] Firmware version %r has not been tested with this driver. " + "Confirmed versions: %s. " + "If this version works correctly, please contribute it to " + "confirmed_firmware_versions.py so others can benefit.", + self.io.human_readable_device_name, + self.firmware_version, + ", ".join(sorted(CONFIRMED_FIRMWARE_VERSIONS)), + ) + + # Set output unit to grams + if "M21" in self._supported_commands: + await self.set_host_unit_grams() + + async def stop(self) -> None: + """Reset the device to a clean state and close the serial connection. + + Sends @ to cancel any pending commands before disconnecting. If the + serial port is already broken (e.g. kernel crash), the reset is skipped + and the port is closed anyway. + """ + try: + await self.reset() + except (OSError, TimeoutError, MettlerToledoError): + logger.warning( + "[%s] Could not reset device before disconnecting", self.io.human_readable_device_name + ) + logger.info("[%s] Disconnected from %s", self.io.human_readable_device_name, self.io.port) + await self.io.stop() + + # === Device discovery === + + async def _request_supported_commands(self) -> Set[str]: + """Query all implemented MT-SICS commands via I0 (Level 0 - always available). + + I0 is the definitive source of command support per spec Section 2.2. + I1 only reports which standardized level sets are fully implemented, + but individual commands may exist outside those levels. + + Returns a set of MT-SICS command strings (e.g. {"@", "S", "SI", "Z", "M21", "M28"}). + """ + responses = await self.send_command("I0") + commands: Set[str] = set() + for resp in responses: + # Format: I0 B/A + if len(resp.data) >= 2: + commands.add(resp.data[1]) + return commands + + # === Response parsing === + + @staticmethod + def _validate_response(response: MettlerToledoResponse, min_fields: int, command: str) -> None: + """Validate that a parsed response has the expected minimum total field count. + + min_fields counts all fields (command + status + data). For example, + a weight response "S S 0.00006 g" has 4 fields total. + + Raises: + MettlerToledoError: if the response has fewer fields than expected. + """ + total = 1 + (1 if response.status else 0) + len(response.data) + if total < min_fields: + raise MettlerToledoError( + title="Unexpected response", + message=f"Expected at least {min_fields} fields for '{command}', got {total}: {response}", + ) + + @staticmethod + def _validate_unit(unit: str, command: str) -> None: + """Validate that the unit in a response is grams. + + Raises: + MettlerToledoError: if the unit is not 'g'. + """ + if unit != "g": + raise MettlerToledoError( + title="Unexpected unit", + message=f"Expected 'g' for '{command}', got '{unit}'", + ) + + def _parse_basic_errors(self, response: MettlerToledoResponse) -> None: + """Helper function for parsing basic errors that are common to many commands. If an error is + detected, a 'MettlerToledoError' exception is raised. + + Error commands (ES, ET, EL) have status="" and no data. + Status codes I, L, +, - indicate command-specific errors. + + Note: B status (multi-response) is handled by send_command, which reads all lines + until status A. Each line is validated through this method individually. + """ + + # General error messages: ES, ET, EL (status is "" for these) + if response.command == "ES": + raise MettlerToledoError.syntax_error() + if response.command == "ET": + raise MettlerToledoError.transmission_error() + if response.command == "EL": + raise MettlerToledoError.logical_error() + + # Status code errors + if response.status == "I": + raise MettlerToledoError.executing_another_command() + if response.status == "L": + raise MettlerToledoError.incorrect_parameter() + if response.status == "+": + raise MettlerToledoError.overload() + if response.status == "-": + raise MettlerToledoError.underload() + + # Weight response error: S S Error + if ( + response.command == "S" + and response.status == "S" + and len(response.data) >= 2 + and response.data[0] == "Error" + ): + error_code = response.data[1] + code, source = error_code[:-1], error_code[-1] + from_terminal = source == "t" + if code == "1": + raise MettlerToledoError.boot_error(from_terminal=from_terminal) + if code == "2": + raise MettlerToledoError.brand_error(from_terminal=from_terminal) + if code == "3": + raise MettlerToledoError.checksum_error(from_terminal=from_terminal) + if code == "9": + raise MettlerToledoError.option_fail(from_terminal=from_terminal) + if code == "10": + raise MettlerToledoError.eeprom_error(from_terminal=from_terminal) + if code == "11": + raise MettlerToledoError.device_mismatch(from_terminal=from_terminal) + if code == "12": + raise MettlerToledoError.hot_plug_out(from_terminal=from_terminal) + if code == "14": + raise MettlerToledoError.weight_module_electronic_mismatch(from_terminal=from_terminal) + if code == "15": + raise MettlerToledoError.adjustment_needed(from_terminal=from_terminal) + raise MettlerToledoError( + title="Unknown weight error", + message=f"Unrecognized error code '{error_code}' in weight response", + ) + + # === Command Layer === + + async def send_command(self, command: str, timeout: int = 60) -> List[MettlerToledoResponse]: + """Send a command to the scale and read all response lines. + + Single-response commands (status A) return a list of one parsed line. + Multi-response commands (status B) return all lines, reading until status A. + + Args: + timeout: The timeout in seconds (applies across all response lines). + + Raises: + MettlerToledoError: If the command is not supported by the connected device. + """ + + cmd = command.split()[0] + if self._supported_commands and cmd not in self._supported_commands: + raise MettlerToledoError( + title="Command not supported", + message=f"MT-SICS command '{cmd}' is not supported by this device.", + ) + + logger.log(LOG_LEVEL_IO, "[%s] Sent command: %s", self.io.human_readable_device_name, command) + await self.io.write(command.encode() + b"\r\n") + + try: + responses: List[MettlerToledoResponse] = [] + timeout_time = time.time() + timeout + while True: + while True: + raw_response = await self.io.readline() + if raw_response != b"": + break + if time.time() > timeout_time: + raise TimeoutError("Timeout while waiting for response from scale.") + await asyncio.sleep(0.001) + + logger.log( + LOG_LEVEL_IO, + "[%s] Received response: %s", + self.io.human_readable_device_name, + raw_response, + ) + fields = shlex.split(raw_response.decode("utf-8").strip()) + if len(fields) >= 2: + response = MettlerToledoResponse(command=fields[0], status=fields[1], data=fields[2:]) + elif len(fields) == 1: + response = MettlerToledoResponse(command=fields[0], status="", data=[]) + else: + response = MettlerToledoResponse(command="", status="", data=[]) + self._parse_basic_errors(response) + responses.append(response) + + # Status B means more responses follow; anything else (A, etc.) is final + if response.status != "B": + break + + return responses + + except (KeyboardInterrupt, asyncio.CancelledError): + # Cancel pending commands without resetting device state (zero/tare). + # Use C (cancel all) if available; otherwise just flush the buffer. + # Never send @ here - it clears zero/tare which the user wants to keep. + if "C" in self._supported_commands: + logger.warning( + "[%s] Command interrupted, sending C to cancel pending commands", + self.io.human_readable_device_name, + ) + await self.io.write(b"C\r\n") + logger.warning( + "[%s] Command interrupted, flushing serial buffer", + self.io.human_readable_device_name, + ) + await self.io.reset_input_buffer() + raise + + # === Public API === + # Organized by function: cancel, identity, zero, tare, weight, measurement, + # configuration (read), display, configuration (write). + + # # Reset and cancel # # + + async def reset(self) -> str: + """@ - Reset the device to a determined state (spec Section 2.2). + + Equivalent to a power cycle: empties volatile memories, resets key control + to default. Tare memory is NOT reset. Always executed, even when busy. + + Returns the serial number from the I4-style response. + """ + await self.io.reset_input_buffer() + responses = await self.send_command("@") + # @ responds with I4-style: I4 A "" + self._validate_response(responses[0], 3, "@") + return responses[0].data[0] + + async def cancel_all(self) -> None: + """C - Cancel all active and pending interface commands. + + Unlike reset() (@), this does not reset the device - it only cancels + commands that were requested via this interface. Typically used to stop + repeating commands (SIR, SR) or abort adjustment procedures. + + This is a multi-response command: the device sends C B (started) then + C A (complete). Both responses are consumed to keep the serial buffer clean. + """ + responses = await self.send_command("C") + # send_command reads both C B (started) and C A (complete) automatically + self._validate_response(responses[0], 2, "C") + if responses[0].status == "E": + raise MettlerToledoError( + title="Error while canceling", + message=f"C command returned error: {responses[0]}", + ) + + # # Device identity # # + + async def request_serial_number(self) -> str: + """Get the serial number of the scale. (I4 command)""" + responses = await self.send_command("I4") + self._validate_response(responses[0], 3, "I4") + return responses[0].data[0] + + async def request_device_type(self) -> str: + """Query the device type string. (I2 command) + + The I2 response packs type, capacity, and unit into a single quoted string: + I2 A "WXS205SDU WXA-Bridge 220.00900 g" + The type is everything before the last two tokens (capacity and unit). + """ + responses = await self.send_command("I2") + self._validate_response(responses[0], 3, "I2") + parts = responses[0].data[0].split() + return " ".join(parts[:-2]) + + async def request_capacity(self) -> float: + """Query the maximum weighing capacity in grams. (I2 command) + + The I2 response packs type, capacity, and unit into a single quoted string: + I2 A "WXS205SDU WXA-Bridge 220.00900 g" + Capacity is the second-to-last token, unit is the last. + """ + responses = await self.send_command("I2") + self._validate_response(responses[0], 3, "I2") + parts = responses[0].data[0].split() + self._validate_unit(parts[-1], "I2") + return float(parts[-2]) + + async def request_firmware_version(self) -> str: + """Query the firmware version and type definition number. (I3 command) + + Returns the version string (e.g. "1.10 18.6.4.1361.772"). + For bridge mode (no terminal), returns the bridge firmware version. + """ + responses = await self.send_command("I3") + self._validate_response(responses[0], 3, "I3") + return responses[0].data[0] + + async def request_software_material_number(self) -> str: + """Query the software material number (SW-ID). (I5 command) + + Unique per software release: 8-digit number + alphabetic index. + For bridge mode (no terminal), returns the bridge SW-ID. + """ + responses = await self.send_command("I5") + self._validate_response(responses[0], 3, "I5") + return responses[0].data[0] + + async def request_device_id(self) -> str: + """Query the user-assigned device identification string. (I10 command) + + This is a user-configurable name (max 20 chars) to identify + individual scales in multi-scale setups. Retained after @ cancel. + """ + responses = await self.send_command("I10") + self._validate_response(responses[0], 3, "I10") + return responses[0].data[0] + + async def set_device_id(self, device_id: str) -> None: + """Set the user-assigned device identification string. (I10 command) + + Max 20 alphanumeric characters. Persists across power cycles. + Useful for labeling individual scales in multi-scale setups. + """ + await self.send_command(f'I10 "{device_id}"') + + async def request_model_designation(self) -> str: + """Query the model designation string. (I11 command) + + Returns the weigh module model type (e.g. "WMS404C-L/10"). + Abbreviations: DR=Delta Range, DU=Dual Range, /M or /A=Approved. + """ + responses = await self.send_command("I11") + self._validate_response(responses[0], 3, "I11") + return responses[0].data[0] + + async def request_device_info(self, category: int = 0) -> List[MettlerToledoResponse]: + """Query detailed device information for a specific category. (I14 command) + + Args: + category: Information category to query: + 0 = instrument configuration (Bridge, Terminal, Option) + 1 = instrument descriptions (model names) + 2 = SW identification numbers + 3 = SW versions + 4 = serial numbers + 5 = TDNR (type definition) numbers + + Returns multi-response with data for each component (bridge, terminal, etc.). + """ + return await self.send_command(f"I14 {category}") + + async def request_uptime_minutes(self) -> int: + """Query the uptime in minutes since last start or restart. (I15 command) + + Returns the number of minutes the device has been running since + the last power-on, start, or reset. Accuracy +/- 5%. + """ + responses = await self.send_command("I15") + self._validate_response(responses[0], 3, "I15") + return int(responses[0].data[0]) + + async def request_date(self) -> str: + """Query the current date from the device. (DAT command) + + Response format: DAT A . + Returns the date as "DD.MM.YYYY". + """ + responses = await self.send_command("DAT") + self._validate_response(responses[0], 5, "DAT") + day, month, year = responses[0].data[0], responses[0].data[1], responses[0].data[2] + return f"{day}.{month}.{year}" + + async def set_date(self, day: int, month: int, year: int) -> None: + """Set the device date. (DAT command) + + Args: + day: Day (1-31). + month: Month (1-12). + year: Year (2020-2099, platform-dependent). + """ + await self.send_command(f"DAT {day:02d} {month:02d} {year}") + + async def request_time(self) -> str: + """Query the current time from the device. (TIM command) + + Response format: TIM A . + Returns the time as "HH:MM:SS". + """ + responses = await self.send_command("TIM") + self._validate_response(responses[0], 5, "TIM") + hour, minute, second = responses[0].data[0], responses[0].data[1], responses[0].data[2] + return f"{hour}:{minute}:{second}" + + async def set_time(self, hour: int, minute: int, second: int) -> None: + """Set the device time. (TIM command) + + Persists across power cycles. Only reset via FSET or terminal menu, not @. + + Args: + hour: Hour (0-23). + minute: Minute (0-59). + second: Second (0-59). + """ + await self.send_command(f"TIM {hour:02d} {minute:02d} {second:02d}") + + async def request_next_service_date(self) -> str: + """Query the date when the balance is next due to be serviced. (I16 command) + + Returns the date as "DD.MM.YYYY". + """ + responses = await self.send_command("I16") + self._validate_response(responses[0], 5, "I16") + day, month, year = responses[0].data[0], responses[0].data[1], responses[0].data[2] + return f"{day}.{month}.{year}" + + async def request_assortment_type_revision(self) -> str: + """Query the revision of assortment type tolerances. (I21 command)""" + responses = await self.send_command("I21") + self._validate_response(responses[0], 3, "I21") + return responses[0].data[0] + + async def request_operating_mode_after_restart(self) -> List[MettlerToledoResponse]: + """Query the operating mode after restart. (I26 command)""" + return await self.send_command("I26") + + # # Zero # # + + async def zero_immediately(self) -> List[MettlerToledoResponse]: + """Zero the scale immediately. (ZI command)""" + return await self.send_command("ZI") + + async def zero_stable(self) -> List[MettlerToledoResponse]: + """Zero the scale when the weight is stable. (Z command)""" + return await self.send_command("Z") + + async def zero_timeout(self, timeout: float) -> List[MettlerToledoResponse]: + """Zero the scale after a given timeout. (ZC command)""" + timeout_ms = int(timeout * 1000) + return await self.send_command(f"ZC {timeout_ms}") + + async def zero(self, timeout: Union[Literal["stable"], float, int] = "stable") -> None: + """Zero the scale. + + Args: + timeout: "stable" waits for stable reading, 0 zeros immediately, + float/int zeros after that many seconds. + """ + if timeout == "stable": + await self.zero_stable() + elif not isinstance(timeout, (float, int)): + raise TypeError("timeout must be a float or 'stable'") + elif timeout < 0: + raise ValueError("timeout must be greater than or equal to 0") + elif timeout == 0: + await self.zero_immediately() + else: + await self.zero_timeout(timeout) + + # # Tare # # + + async def tare_stable(self) -> List[MettlerToledoResponse]: + """Tare the scale when the weight is stable. (T command)""" + return await self.send_command("T") + + async def tare_immediately(self) -> List[MettlerToledoResponse]: + """Tare the scale immediately. (TI command)""" + return await self.send_command("TI") + + async def tare_timeout(self, timeout: float) -> List[MettlerToledoResponse]: + """Tare the scale after a given timeout. (TC command)""" + timeout_ms = int(timeout * 1000) + return await self.send_command(f"TC {timeout_ms}") + + async def tare(self, timeout: Union[Literal["stable"], float, int] = "stable") -> None: + """Tare the scale. + + Args: + timeout: "stable" waits for stable reading, 0 tares immediately, + float/int tares after that many seconds. + """ + if timeout == "stable": + await self.tare_stable() + elif not isinstance(timeout, (float, int)): + raise TypeError("timeout must be a float or 'stable'") + elif timeout < 0: + raise ValueError("timeout must be greater than or equal to 0") + elif timeout == 0: + await self.tare_immediately() + else: + await self.tare_timeout(timeout) + + async def request_tare_weight(self) -> float: + """Query tare weight value from scale's memory. (TA command)""" + responses = await self.send_command("TA") + self._validate_response(responses[0], 4, "TA") + self._validate_unit(responses[0].data[1], "TA") + return float(responses[0].data[0]) + + async def clear_tare(self) -> List[MettlerToledoResponse]: + """Clear tare weight value. (TAC command)""" + return await self.send_command("TAC") + + # # Weight measurement # # + + async def read_stable_weight(self) -> float: + """Read a stable weight value from the scale. (MEASUREMENT command) + + from the docs: + + "Use S to send a stable weight value, along with the host unit, from the balance to + the connected communication partner via the interface. If the automatic door function + is enabled and a stable weight is requested the balance will open and close the balance's + doors to achieve a stable weight." + """ + + responses = await self.send_command("S") + self._validate_response(responses[0], 4, "S") + self._validate_unit(responses[0].data[1], "S") + return float(responses[0].data[0]) + + async def read_dynamic_weight(self, timeout: float) -> float: + """Read a stable weight value within a given timeout, or return the current + weight value if stability is not reached. (SC command) + + Args: + timeout: The timeout in seconds. + """ + timeout_ms = int(timeout * 1000) + responses = await self.send_command(f"SC {timeout_ms}") + self._validate_response(responses[0], 4, "SC") + self._validate_unit(responses[0].data[1], "SC") + return float(responses[0].data[0]) + + async def read_weight_value_immediately(self) -> float: + """Read a weight value immediately from the scale. (SI command)""" + responses = await self.send_command("SI") + self._validate_response(responses[0], 4, "SI") + self._validate_unit(responses[0].data[1], "SI") + return float(responses[0].data[0]) + + async def read_weight(self, timeout: Union[Literal["stable"], float, int] = "stable") -> float: + """High level function to read a weight value from the scale. (MEASUREMENT command) + + Args: + timeout: The timeout in seconds. If "stable", the scale will return a weight value when the + weight is stable. If 0, the scale will return a weight value immediately. If a float/int, + the scale will return a weight value after the given timeout (in seconds). + """ + + if timeout == "stable": + return await self.read_stable_weight() + + if not isinstance(timeout, (float, int)): + raise TypeError("timeout must be a float or 'stable'") + + if timeout < 0: + raise ValueError("timeout must be greater than or equal to 0") + + if timeout == 0: + return await self.read_weight_value_immediately() + + return await self.read_dynamic_weight(timeout) + + async def measure_temperature(self) -> float: + """Read the current temperature from the scale's internal sensor in degrees C. (M28 command) + + The number of temperature sensors depends on the product. This method returns + the value from the first sensor. Useful for gravimetric verification where + temperature affects liquid density and evaporation rate. + """ + responses = await self.send_command("M28") + self._validate_response(responses[0], 4, "M28") + return float(responses[0].data[1]) + + async def request_net_weight_with_status(self) -> MettlerToledoResponse: + """Query net weight with unit and weighing status in one call. (SIS command) + + Response data fields: + + - data[0] = State: 0=stable, 1=dynamic, 2=stable inaccurate (MinWeigh), 3=dynamic inaccurate, 4=overload, 5=underload, 6=error + - data[1] = Net weight value + - data[2] = Unit code: 0=g, 1=kg, 3=mg, 4=ug, 5=ct, 7=lb, 8=oz, etc. + - data[3] = Readability (number of decimal places, 0-6) + - data[4] = Step: 1, 2, 5, 10, 20, 50, or 100 + - data[5] = Approval: 0=standard (not approved), 1=e=d, 10=e=10d, 100=e=100d, -1=unapproved + - data[6] = Info: 0=without tare, 1=net with weighed tare, 2=net with stored tare + """ + responses = await self.send_command("SIS") + return responses[0] + + async def read_stable_weight_repeat_on_change(self) -> List[MettlerToledoResponse]: + """Start sending stable weight values on every stable weight change. (SNR command) + + The device sends a new value each time the weight changes and stabilizes. + Use reset() to stop. + """ + return await self.send_command("SNR") + + # # Device configuration (read-only) # # + + async def request_weighing_mode(self) -> int: + """Query the current weighing mode. (M01 command) + + Returns: 0=Normal/Universal, 1=Dosing, 2=Sensor, 3=Check weighing, 6=Raw/No filter. + """ + responses = await self.send_command("M01") + self._validate_response(responses[0], 3, "M01") + return int(responses[0].data[0]) + + # async def set_weighing_mode(self, mode: int) -> None: + # """Set weighing mode. (M01) WRITES TO DEVICE MEMORY.""" + # await self.send_command(f"M01 {mode}") + + async def request_environment_condition(self) -> int: + """Query the current environment condition setting. (M02 command) + + Returns: 0=Very stable, 1=Stable, 2=Standard, 3=Unstable, 4=Very unstable, 5=Automatic. + Affects the scale's internal filter and stability detection. + """ + responses = await self.send_command("M02") + self._validate_response(responses[0], 3, "M02") + return int(responses[0].data[0]) + + # async def set_environment_condition(self, condition: int) -> None: + # """Set environment condition. (M02) WRITES TO DEVICE MEMORY.""" + # await self.send_command(f"M02 {condition}") + + async def request_auto_zero(self) -> int: + """Query the current auto zero setting. (M03 command) + + Returns: 0=off, 1=on. Auto zero compensates for slow drift + (e.g. evaporation, temperature changes) by automatically + re-zeroing when the weight is near zero and stable. + """ + responses = await self.send_command("M03") + self._validate_response(responses[0], 3, "M03") + return int(responses[0].data[0]) + + # async def set_auto_zero(self, enabled: int) -> None: + # """Set auto zero. (M03) WRITES TO DEVICE MEMORY.""" + # await self.send_command(f"M03 {enabled}") + + async def request_profact_time_criteria(self) -> List[MettlerToledoResponse]: + """Query ProFACT single time criteria. (M17 command)""" + return await self.send_command("M17") + + # async def set_profact_time_criteria(self, ...) -> None: + # """Set ProFACT time criteria. (M17) WRITES TO DEVICE MEMORY.""" + # ... + + async def request_profact_temperature_criterion(self) -> List[MettlerToledoResponse]: + """Query ProFACT/FACT temperature criterion. (M18 command)""" + return await self.send_command("M18") + + # async def set_profact_temperature_criterion(self, ...) -> None: + # """Set ProFACT temperature criterion. (M18) WRITES TO DEVICE MEMORY.""" + # ... + + async def request_adjustment_weight(self) -> List[MettlerToledoResponse]: + """Query the adjustment weight setting. (M19 command)""" + return await self.send_command("M19") + + # async def set_adjustment_weight(self, ...) -> None: + # """Set adjustment weight. (M19) WRITES TO DEVICE MEMORY.""" + # ... + + async def request_test_weight(self) -> List[MettlerToledoResponse]: + """Query the test weight setting. (M20 command)""" + return await self.send_command("M20") + + # async def set_test_weight(self, ...) -> None: + # """Set test weight. (M20) WRITES TO DEVICE MEMORY.""" + # ... + + async def request_weighing_value_release(self) -> List[MettlerToledoResponse]: + """Query the weighing value release setting. (M29 command)""" + return await self.send_command("M29") + + # async def set_weighing_value_release(self, ...) -> None: + # """Set weighing value release. (M29) WRITES TO DEVICE MEMORY.""" + # ... + + async def request_operating_mode(self) -> List[MettlerToledoResponse]: + """Query the operating mode after restart. (M31 command)""" + return await self.send_command("M31") + + # async def set_operating_mode(self, ...) -> None: + # """Set operating mode after restart. (M31) WRITES TO DEVICE MEMORY.""" + # ... + + async def request_profact_time(self) -> List[MettlerToledoResponse]: + """Query ProFACT time criteria. (M32 command)""" + return await self.send_command("M32") + + # async def set_profact_time(self, ...) -> None: + # """Set ProFACT time. (M32) WRITES TO DEVICE MEMORY.""" + # ... + + async def request_profact_day(self) -> List[MettlerToledoResponse]: + """Query ProFACT day of the week. (M33 command)""" + return await self.send_command("M33") + + # async def set_profact_day(self, ...) -> None: + # """Set ProFACT day of the week. (M33) WRITES TO DEVICE MEMORY.""" + # ... + + async def request_zeroing_mode(self) -> List[MettlerToledoResponse]: + """Query the zeroing mode at startup. (M35 command)""" + return await self.send_command("M35") + + # async def set_zeroing_mode(self, ...) -> None: + # """Set zeroing mode at startup. (M35) WRITES TO DEVICE MEMORY.""" + # ... + + async def request_update_rate(self) -> float: + """Query the current update rate for SIR/SIRU streaming. (UPD command) + + Returns the update rate in values per second. + """ + responses = await self.send_command("UPD") + self._validate_response(responses[0], 3, "UPD") + return float(responses[0].data[0]) + + # async def set_update_rate(self, rate: float) -> None: + # """Set streaming update rate. (UPD) WRITES TO DEVICE MEMORY.""" + # await self.send_command(f"UPD {rate}") + + async def request_adjustment_setting(self) -> List[MettlerToledoResponse]: + """Query the current adjustment setting. (C0 command)""" + return await self.send_command("C0") + + # async def set_adjustment_setting(self, ...) -> None: + # """Set adjustment setting. (C0) WRITES TO DEVICE MEMORY.""" + # ... + + async def request_serial_parameters(self) -> List[MettlerToledoResponse]: + """Query current serial interface parameters. (COM command)""" + return await self.send_command("COM") + + # async def set_serial_parameters(self, ...) -> None: + # """Set serial port parameters. (COM) WRITES TO DEVICE MEMORY. + # WARNING: changing baud rate will lose communication.""" + # ... + + async def request_filter_cutoff(self) -> List[MettlerToledoResponse]: + """Query the filter cut-off frequency. (FCUT command)""" + return await self.send_command("FCUT") + + # async def set_filter_cutoff(self, frequency: float) -> None: + # """Set filter cut-off frequency. (FCUT) WRITES TO DEVICE MEMORY.""" + # await self.send_command(f"FCUT {frequency}") + + async def request_stability_criteria(self) -> List[MettlerToledoResponse]: + """Query the user-defined stability criteria. (USTB command)""" + return await self.send_command("USTB") + + # async def set_stability_criteria(self, ...) -> None: + # """Set stability criteria. (USTB) WRITES TO DEVICE MEMORY.""" + # ... + + async def request_test_settings(self) -> List[MettlerToledoResponse]: + """Query current test function settings. (TST0 command)""" + return await self.send_command("TST0") + + # async def set_test_settings(self, ...) -> None: + # """Set test function settings. (TST0) WRITES TO DEVICE MEMORY.""" + # ... + + async def request_remaining_weighing_range(self) -> float: + """Query remaining maximum weighing range in grams. (I50 command) + + Returns the remaining capacity accounting for all loads currently on the + weighing platform (pre-load, tare, net load). A negative value means the + maximum weighing range has been exceeded. + + Multi-response: the device sends up to 3 lines (B, B, A). + """ + responses = await self.send_command("I50") + self._validate_response(responses[0], 5, "I50") + self._validate_unit(responses[0].data[2], "I50") + return float(responses[0].data[1]) + + async def request_adjustment_history(self) -> List[MettlerToledoResponse]: + """Query the adjustment (calibration) history. (M27 command) + + Returns multi-response with each adjustment entry containing: + entry number, date, time, mode (0=built-in, 1=external), and weight used. + """ + return await self.send_command("M27") + + async def request_user_settings(self) -> List[MettlerToledoResponse]: + """Query all current user-configurable settings. (LST command) + + Returns a multi-response listing every configurable parameter and its value. + """ + return await self.send_command("LST") + + async def request_readability(self) -> List[MettlerToledoResponse]: + """Query the readability setting. (RDB command)""" + return await self.send_command("RDB") + + # # Display # # + + async def set_display_text(self, text: str) -> List[MettlerToledoResponse]: + """Write text to the display. (D command) + + Use set_weight_display() to restore the normal weight display. + """ + return await self.send_command(f'D "{text}"') + + async def set_weight_display(self) -> List[MettlerToledoResponse]: + """Restore the normal weight display. (DW command)""" + return await self.send_command("DW") + + # # Configuration (write - no corresponding query) # # + + async def set_host_unit_grams(self) -> List[MettlerToledoResponse]: + """Set the host output unit to grams. (M21 command) + + Called automatically during setup() if supported. + """ + return await self.send_command("M21 0 0") + + # # Commented out - standalone write commands # # + # + # async def factory_reset(self, exclusion: int = 0) -> None: + # """Reset ALL settings to factory defaults. (FSET) DESTRUCTIVE.""" + # await self.send_command(f"FSET {exclusion}") + + # # Commented out - require physical interaction or architecture changes # # + # + # async def start_adjustment(self) -> List[MettlerToledoResponse]: + # """Start adjustment. (C1) Moves internal calibration weights.""" + # return await self.send_command("C1") + # + # async def start_adjustment_external_weight(self) -> List[MettlerToledoResponse]: + # """Adjust with external weight. (C2) Requires placing calibration weight.""" + # return await self.send_command("C2") + # + # async def start_adjustment_builtin_weight(self) -> List[MettlerToledoResponse]: + # """Adjust with built-in weight. (C3) Moves internal weights.""" + # return await self.send_command("C3") + # + # async def start_test(self) -> List[MettlerToledoResponse]: + # """Run test according to current settings. (TST1) Moves internal weights.""" + # return await self.send_command("TST1") + # + # async def start_test_external_weight(self) -> List[MettlerToledoResponse]: + # """Run test with external weight. (TST2) Requires placing test weight.""" + # return await self.send_command("TST2") + # + # async def start_test_builtin_weight(self) -> List[MettlerToledoResponse]: + # """Run test with built-in weight. (TST3) Moves internal weights.""" + # return await self.send_command("TST3") + # + # async def read_weight_immediately_repeat(self) -> ...: + # """Stream weight values at update rate. (SIR) Needs async iterator.""" + # ... + # + # async def read_stable_weight_repeat(self) -> ...: + # """Stream stable weight on change. (SR) Needs async iterator.""" + # ... diff --git a/pylabrobot/mettler_toledo/scales/driver_tests.py b/pylabrobot/mettler_toledo/scales/driver_tests.py new file mode 100644 index 00000000000..d3271ed6901 --- /dev/null +++ b/pylabrobot/mettler_toledo/scales/driver_tests.py @@ -0,0 +1,100 @@ +"""Tests for MT-SICS response parsing, validation, and protocol simulation.""" + +import unittest + +from pylabrobot.mettler_toledo.scales import ( + MettlerToledoError, + MettlerToledoResponse, + MTSICSDriver, +) + +R = MettlerToledoResponse + + +class MTSICSResponseParsingTests(unittest.TestCase): + """Tests for response parsing helpers - no hardware or simulator needed.""" + + def setUp(self): + self.driver = MTSICSDriver.__new__(MTSICSDriver) + + def test_parse_errors_ES_ET_EL(self): + """General error codes (ES, ET, EL) must raise the correct MettlerToledoError. + These are the first line of defense against protocol-level failures.""" + with self.assertRaises(MettlerToledoError) as ctx: + self.driver._parse_basic_errors(R("ES", "")) + self.assertIn("Syntax error", str(ctx.exception)) + + with self.assertRaises(MettlerToledoError) as ctx: + self.driver._parse_basic_errors(R("ET", "")) + self.assertIn("Transmission error", str(ctx.exception)) + + with self.assertRaises(MettlerToledoError) as ctx: + self.driver._parse_basic_errors(R("EL", "")) + self.assertIn("Logical error", str(ctx.exception)) + + def test_parse_errors_status_codes(self): + """Command-specific status codes (I, L, +, -) must raise descriptive errors. + These catch device-busy, bad parameters, and overload/underload conditions.""" + with self.assertRaises(MettlerToledoError) as ctx: + self.driver._parse_basic_errors(R("S", "I")) + self.assertIn("not executable at present", str(ctx.exception)) + + with self.assertRaises(MettlerToledoError) as ctx: + self.driver._parse_basic_errors(R("S", "L")) + self.assertIn("incorrect parameter", str(ctx.exception)) + + with self.assertRaises(MettlerToledoError) as ctx: + self.driver._parse_basic_errors(R("S", "+")) + self.assertIn("overload", str(ctx.exception)) + + with self.assertRaises(MettlerToledoError) as ctx: + self.driver._parse_basic_errors(R("S", "-")) + self.assertIn("underload", str(ctx.exception)) + + def test_validate_response_rejects_short(self): + """Responses with fewer fields than expected must be rejected. + Prevents silent IndexError when accessing data fields.""" + with self.assertRaises(MettlerToledoError): + MTSICSDriver._validate_response(R("I4", "A"), 3, "I4") + + # should not raise + MTSICSDriver._validate_response(R("I4", "A", ["B207696838"]), 3, "I4") + + def test_validate_unit_rejects_wrong(self): + """Non-gram unit responses must be rejected. + The driver assumes grams throughout - a wrong unit would produce wrong values.""" + with self.assertRaises(MettlerToledoError): + MTSICSDriver._validate_unit("kg", "S") + + # should not raise + MTSICSDriver._validate_unit("g", "S") + + def test_parse_errors_passes_valid_success(self): + """A valid success response (status A) must not raise. + Ensures the happy path is not accidentally blocked.""" + self.driver._parse_basic_errors(R("Z", "A")) + + def test_parse_errors_weight_response_error(self): + """S S Error responses (hardware faults detected during weighing) must raise. + These indicate boot errors, EEPROM failures, etc. on the physical device.""" + with self.assertRaises(MettlerToledoError) as ctx: + self.driver._parse_basic_errors(R("S", "S", ["Error", "10b"])) + self.assertIn("EEPROM error", str(ctx.exception)) + + def test_dataclass_construction(self): + """MettlerToledoResponse dataclass must correctly separate command, status, and data. + This is the foundation for all response access throughout the driver.""" + resp = R("S", "S", ["0.00006", "g"]) + self.assertEqual(resp.command, "S") + self.assertEqual(resp.status, "S") + self.assertEqual(resp.data, ["0.00006", "g"]) + + # Error-only response (no status) + resp = R("ES", "") + self.assertEqual(resp.command, "ES") + self.assertEqual(resp.status, "") + self.assertEqual(resp.data, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/mettler_toledo/scales/errors.py b/pylabrobot/mettler_toledo/scales/errors.py new file mode 100644 index 00000000000..59ee3a38d9d --- /dev/null +++ b/pylabrobot/mettler_toledo/scales/errors.py @@ -0,0 +1,137 @@ +"""MT-SICS error types and response codes (spec Sections 2.1.3.1 - 2.1.3.3).""" + +from typing import Optional + + +class MettlerToledoError(Exception): + """Exceptions raised by a Mettler Toledo scale.""" + + def __init__(self, title: str, message: Optional[str] = None) -> None: + self.title = title + self.message = message + + def __str__(self) -> str: + return f"{self.title}: {self.message}" + + # -- General errors (spec Section 2.1.3.2) -- + + @staticmethod + def unknown_error() -> "MettlerToledoError": + return MettlerToledoError(title="Unknown error", message="An unknown error occurred") + + @staticmethod + def syntax_error() -> "MettlerToledoError": + return MettlerToledoError( + title="Syntax error", + message="The weigh module/balance has not recognized the received command or the command is " + "not allowed", + ) + + @staticmethod + def transmission_error() -> "MettlerToledoError": + return MettlerToledoError( + title="Transmission error", + message="The weigh module/balance has received a 'faulty' command, e.g. owing to a parity " + "error or interface break", + ) + + @staticmethod + def logical_error() -> "MettlerToledoError": + return MettlerToledoError( + title="Logical error", + message="The weigh module/balance can not execute the received command", + ) + + # -- Command-specific status codes (spec Section 2.1.3.1) -- + + @staticmethod + def executing_another_command() -> "MettlerToledoError": + return MettlerToledoError( + title="Command not understood, not executable at present", + message=( + "Command understood but currently not executable (balance is " + "currently executing another command)." + ), + ) + + @staticmethod + def incorrect_parameter() -> "MettlerToledoError": + return MettlerToledoError( + title="Command understood but not executable", + message="(incorrect parameter).", + ) + + @staticmethod + def overload() -> "MettlerToledoError": + return MettlerToledoError(title="Balance in overload range.", message=None) + + @staticmethod + def underload() -> "MettlerToledoError": + return MettlerToledoError(title="Balance in underload range.", message=None) + + # -- Weight response error codes (spec Section 2.1.3.3) -- + + @staticmethod + def boot_error(from_terminal: bool) -> "MettlerToledoError": + return MettlerToledoError( + title="Boot error", + message="from terminal" if from_terminal else "from electronics", + ) + + @staticmethod + def brand_error(from_terminal: bool) -> "MettlerToledoError": + return MettlerToledoError( + title="Brand error", + message="from terminal" if from_terminal else "from electronics", + ) + + @staticmethod + def checksum_error(from_terminal: bool) -> "MettlerToledoError": + return MettlerToledoError( + title="Checksum error", + message="from terminal" if from_terminal else "from electronics", + ) + + @staticmethod + def option_fail(from_terminal: bool) -> "MettlerToledoError": + return MettlerToledoError( + title="Option fail", + message="from terminal" if from_terminal else "from electronics", + ) + + @staticmethod + def eeprom_error(from_terminal: bool) -> "MettlerToledoError": + return MettlerToledoError( + title="EEPROM error", + message="from terminal" if from_terminal else "from electronics", + ) + + @staticmethod + def device_mismatch(from_terminal: bool) -> "MettlerToledoError": + return MettlerToledoError( + title="Device mismatch", + message="from terminal" if from_terminal else "from electronics", + ) + + @staticmethod + def hot_plug_out(from_terminal: bool) -> "MettlerToledoError": + return MettlerToledoError( + title="Hot plug out", + message="from terminal" if from_terminal else "from electronics", + ) + + @staticmethod + def weight_module_electronic_mismatch( + from_terminal: bool, + ) -> "MettlerToledoError": + return MettlerToledoError( + title="Weight module / electronic mismatch", + message="from terminal" if from_terminal else "from electronics", + ) + + @staticmethod + def adjustment_needed(from_terminal: bool) -> "MettlerToledoError": + return MettlerToledoError( + title="Adjustment needed", + message="from terminal" if from_terminal else "from electronics", + ) diff --git a/pylabrobot/mettler_toledo/scales/mt_sics_commands.md b/pylabrobot/mettler_toledo/scales/mt_sics_commands.md new file mode 100644 index 00000000000..6bac6a37de3 --- /dev/null +++ b/pylabrobot/mettler_toledo/scales/mt_sics_commands.md @@ -0,0 +1,318 @@ +# MT-SICS Command Reference + +MT-SICS = Mettler Toledo Standard Interface Command Set + +Commands organized by level and ranked by utility for PyLabRobot integration. +Source: MT-SICS Interface Command Set for Automated Precision Weigh Modules (spec doc). + +**Important:** I1 reports which standardized level sets are fully implemented, but +individual commands may exist outside those levels. I0 is the definitive source of +command support. During setup(), the driver queries I0 to discover all available +commands. `send_command` checks the command against this list and raises if unsupported. + +**Hardware-validated on WXS205SDU WXA-Bridge (S/N: B207696838, firmware: 1.10):** +I1 reports levels [0, 1] but I0 discovers 62 commands across levels 0-3. +Commands not in I0 (C, D, DW, SC, ZC, TC, I50) return ES (syntax error). + +Status key: +- DONE = implemented in driver.py (read active; set active or commented out per write safety) +- STUB = commented out entirely (requires physical interaction) +- HIGH = high priority for implementation +- MED = medium priority +- LOW = low priority / niche use case +- N/A = not applicable to automation use case +- WXS205SDU column: supported/not supported on our test device + +## Level 0 - Basic Set (always available) + +| Command | Description | Spec Page | Status | WXS205SDU | Notes | +|---------|------------------------------------------|-----------|--------|-----------|-------| +| @ | Reset device to determined state | 16 | DONE | yes | reset(). Sent during setup(). Response is I4-style. | +| I0 | List all implemented commands + levels | 96 | DONE | yes | _request_supported_commands(). Queried during setup(). | +| I1 | MT-SICS level and level versions | 97 | DONE | yes | Not used for gating - I0 is authoritative. | +| I2 | Device data (type and capacity) | 98 | DONE | yes | request_device_type() and request_capacity(). Response is one quoted string parsed with shlex. | +| I3 | Firmware version and type definition | 99 | DONE | yes | request_firmware_version(). Returns "1.10 18.6.4.1361.772" on test device. | +| I4 | Serial number | 100 | DONE | yes | request_serial_number(). | +| I5 | Software material number | 101 | DONE | yes | request_software_material_number(). Returns "11671158C" on test device. | +| S | Stable weight value | 223 | DONE | yes | read_stable_weight(). | +| SI | Weight value immediately | 225 | DONE | yes | read_weight_value_immediately(). | +| SIR | Weight immediately + repeat | 232 | MED | yes | Continuous streaming. Needs async iterator architecture. | +| SIRU | Weight immediately + repeat (display unit) | - | LOW | - | Streaming variant in display unit. | +| Z | Zero (wait for stable) | 272 | DONE | yes | zero_stable(). | +| ZI | Zero immediately | 274 | DONE | yes | zero_immediately(). | + +## Level 1 - Elementary Commands (always available) + +| Command | Description | Spec Page | Status | WXS205SDU | Notes | +|---------|------------------------------------------|-----------|--------|-----------|-------| +| C | Cancel all pending commands | 23 | DONE | **no** | cancel_all(). Not supported on WXS205SDU bridge. | +| D | Write text to display | 52 | DONE | **no** | set_display_text(). Not supported in bridge mode (no terminal). | +| DW | Show weight on display | 61 | DONE | **no** | set_weight_display(). Not supported in bridge mode. | +| K | Keys control | 153 | LOW | - | Lock/unlock terminal keys. | +| SC | Stable or dynamic value after timeout | 224 | DONE | **no** | read_dynamic_weight(). Not supported on WXS205SDU. | +| SR | Stable weight + repeat on any change | 245 | MED | yes | Continuous streaming. Needs async iterator architecture. | +| SRU | Stable weight + repeat (display unit) | 247 | LOW | - | | +| T | Tare (wait for stable) | 252 | DONE | yes | tare_stable(). | +| TA | Tare weight value (query/set) | 253 | DONE | yes | request_tare_weight(). | +| TAC | Clear tare weight value | 254 | DONE | yes | clear_tare(). | +| TC | Tare with timeout | 255 | DONE | **no** | tare_timeout(). Not supported on WXS205SDU. | +| TI | Tare immediately | 257 | DONE | yes | tare_immediately(). | +| ZC | Zero with timeout | 273 | DONE | **no** | zero_timeout(). Not supported on WXS205SDU. | + +## Level 2 - Extended Commands (model-dependent) + +### Device Information (query) + +| Command | Description | Spec Page | Status | WXS205SDU | Notes | +|---------|------------------------------------------|-----------|--------|-----------|-------| +| I10 | Device identification | 102 | DONE | yes | request_device_id() and set_device_id(). Labels individual scales in multi-scale setups. | +| I11 | Model designation | 103 | DONE | yes | request_model_designation(). Returns "WXS205SDU" on test device. | +| I14 | Device information (detailed) | 104 | DONE | yes | request_device_info(). Multi-response with config, descriptions, SW IDs, serial numbers. | +| I15 | Uptime in minutes since start/restart | 106 | DONE | yes | request_uptime_minutes(). Returns minutes, accuracy +/- 5%. | +| I16 | Date of next service | 107 | DONE | yes | request_next_service_date(). | +| I21 | Revision of assortment type tolerances | 108 | DONE | yes | request_assortment_type_revision(). | +| I26 | Operating mode after restart | - | DONE | yes | request_operating_mode_after_restart(). Not in spec but on WXS205SDU via I0. | +| I27 | Undocumented | - | LOW | - | In spec TOC but no documentation found. | +| I29 | Filter configuration | 111 | LOW | - | | +| I32 | Voltage monitoring | 112 | MED | - | | +| I43 | Selectable units for host unit | 113 | LOW | - | | +| I44 | Selectable units for display unit | 114 | LOW | - | | +| I45 | Selectable environment filter settings | 115 | LOW | - | | +| I46 | Selectable weighing modes | 117 | LOW | - | | +| I47 | Switch-on range | 118 | LOW | - | | +| I48 | Initial zero range | 119 | LOW | - | | +| I50 | Remaining weighing ranges | 120 | DONE | **no** | request_remaining_weighing_range(). Not on WXS205SDU. | +| I51 | Power-on time | 121 | MED | - | | +| I52 | Auto zero activation settings | 122 | LOW | - | | +| I54 | Adjustment loads | 125 | LOW | - | | +| I55 | Menu version | 126 | LOW | - | | +| I56 | Undocumented | - | LOW | - | In spec TOC but no documentation found. | +| I59 | Initial zero information | 129 | LOW | - | | +| I62 | Timeout setting | 131 | LOW | - | | +| I65 | Total operating time | 132 | MED | - | | +| I66 | Total load weighed | 133 | MED | - | | +| I67 | Total number of weighings | 134 | MED | - | | +| I69 | Service provider address | 135 | LOW | - | | +| I71 | One time adjustment status | 136 | LOW | - | | +| I73 | Sign off | 137 | LOW | - | | +| I74 | GEO code at calibration point (HighRes) | 138 | LOW | - | | +| I75 | GEO code at point of use (HighRes) | 139 | LOW | - | | +| I76 | Total voltage exceeds | 140 | LOW | - | | +| I77 | Total load cycles | 141 | MED | - | | +| I78 | Zero deviation | 143 | LOW | - | | +| I79 | Total zero deviation exceeds | 144 | LOW | - | | +| I80 | Total temperature exceeds | 145 | LOW | - | | +| I81 | Temperature gradient | 147 | LOW | - | | +| I82 | Total temperature gradient exceeds | 148 | LOW | - | | +| I83 | Software identification | 149 | LOW | - | | +| I100 | Active stability criteria | 151 | LOW | - | | +| I101 | Humidity value | 152 | LOW | - | | + +### Configuration (read/write) + +| Command | Description | Spec Page | Status | WXS205SDU | Notes | +|---------|------------------------------------------|-----------|--------|-----------|-------| +| M01 | Weighing mode | 157 | DONE | yes | request_weighing_mode() (read). set commented out (persists to memory). | +| M02 | Environment condition | 158 | DONE | yes | request_environment_condition() (read). set commented out (persists to memory). | +| M03 | Auto zero function | 159 | DONE | yes | request_auto_zero() (read). set commented out (persists to memory). | +| M21 | Unit (host/display) | 165 | DONE | yes | set_host_unit_grams(). | +| M23 | Readability (1d/xd) | 169 | LOW | - | | +| M28 | Temperature value | 172 | DONE | yes | measure_temperature(). Returns 19.8-19.9 C on test device. | +| M29 | Weighing value release | - | DONE | yes | request_weighing_value_release() (read). set commented out (persists to memory). | +| M35 | Zeroing mode at startup | 178 | DONE | yes | request_zeroing_mode() (read). set commented out (persists to memory). | +| M49 | Permanent tare mode | 188 | LOW | - | | +| M67 | Timeout | 191 | LOW | - | | +| M68 | Behavior of serial interfaces | 192 | LOW | - | | +| COM | Serial interface parameters | 46 | DONE | yes | request_serial_parameters(). set commented out (persists to memory). | +| ECHO | Echo mode | 66 | LOW | - | | +| LST | Current user settings | 156 | DONE | yes | request_user_settings(). Level 3 on WXS205SDU. | +| PROT | Protocol mode | 220 | LOW | - | | + +### Adjustment / Calibration + +| Command | Description | Spec Page | Status | WXS205SDU | Notes | +|---------|------------------------------------------|-----------|--------|-----------|-------| +| C0 | Adjustment setting | 24 | DONE | yes | request_adjustment_setting() (read). set commented out (persists to memory). | +| C1 | Start adjustment (current settings) | 26 | STUB | yes | Commented out (moves internal weights). Multi-response. | +| C2 | Start adjustment (external weight) | 28 | STUB | yes | Commented out (requires placing external weight). | +| C3 | Start adjustment (built-in weight) | 30 | STUB | yes | Commented out (moves internal weights). Multi-response. | +| C4 | Standard / initial adjustment | 31 | LOW | - | | +| C5 | Enable/disable step control | 33 | LOW | - | | +| C6 | Customer linearization + sensitivity | 34 | LOW | - | | +| C7 | Customer standard calibration | 37 | LOW | - | | +| C8 | Sensitivity adjustment | 40 | LOW | - | | +| C9 | Scale placement sensitivity adjustment | 43 | LOW | - | | +| M19 | Adjustment weight | 163 | DONE | yes | request_adjustment_weight() (read). set commented out (persists to memory). | +| M20 | Test weight | - | DONE | yes | request_test_weight() (read). set commented out (persists to memory). | +| M27 | Adjustment history | 171 | DONE | yes | request_adjustment_history(). Multi-response. | + +### Testing + +| Command | Description | Spec Page | Status | WXS205SDU | Notes | +|---------|------------------------------------------|-----------|--------|-----------|-------| +| TST0 | Query/set test function settings | 259 | DONE | yes | request_test_settings() (read). set commented out (persists to memory). | +| TST1 | Test according to current settings | 260 | STUB | yes | Commented out (moves internal weights). | +| TST2 | Test with external weight | 262 | STUB | yes | Commented out (requires placing test weight). | +| TST3 | Test with built-in weight | 264 | STUB | yes | Commented out (moves internal weights). | +| TST5 | Module test with built-in weights | 265 | LOW | - | | + +### Weight Variants (alternative read commands) + +| Command | Description | Spec Page | Status | WXS205SDU | Notes | +|---------|------------------------------------------|-----------|--------|-----------|-------| +| SIC1 | Weight with CRC16 immediately | 226 | LOW | - | | +| SIC2 | HighRes weight with CRC16 immediately | 227 | LOW | - | | +| SIS | Net weight with unit + weighing status | 234 | DONE | yes | request_net_weight_with_status(). | +| SIU | Weight in display unit immediately | 237 | LOW | - | | +| SIUM | Weight + MinWeigh info immediately | 238 | LOW | - | | +| SIX1 | Current gross, net, and tare values | 239 | HIGH | - | Not on WXS205SDU. | +| SNR | Stable weight + repeat on stable change | 241 | DONE | yes | read_stable_weight_repeat_on_change(). Use reset() to stop. | +| ST | Stable weight on Transfer key press | 249 | N/A | - | Manual operation. | +| SU | Stable weight in display unit | 250 | LOW | - | | +| SUM | Stable weight + MinWeigh info | 251 | LOW | - | | + +### Stored Weight + +| Command | Description | Spec Page | Status | WXS205SDU | Notes | +|---------|------------------------------------------|-----------|--------|-----------|-------| +| SIMC | Clear stored weight value | 228 | LOW | - | | +| SIMR | Recall stored weight value | 229 | LOW | - | | +| SIMRC | Recall and clear stored weight value | 230 | LOW | - | | +| SIMS | Store weight immediately | 231 | LOW | - | | + +### Date/Time + +| Command | Description | Spec Page | Status | WXS205SDU | Notes | +|---------|------------------------------------------|-----------|--------|-----------|-------| +| DAT | Date (query/set) | 53 | DONE | yes | request_date() and set_date(). Format: DAT A Day Month Year. | +| DATI | Date and time (query/set) | 54 | MED | - | Combined date+time. Not on WXS205SDU. | +| TIM | Time (query/set) | 258 | DONE | yes | request_time() and set_time(). Format: TIM A Hour Minute Second. Persists (not reset by @). | + +### Digital I/O + +| Command | Description | Spec Page | Status | WXS205SDU | Notes | +|---------|------------------------------------------|-----------|--------|-----------|-------| +| DIN | Configuration for digital inputs | 55 | LOW | - | | +| DIS | Digital input status | 56 | LOW | - | | +| DOS | Digital output status | 57 | LOW | - | | +| DOT | Configuration for digital outputs | 58 | LOW | - | | +| DOTC | Configurable digital outputs (weight) | 59 | LOW | - | | + +### System / Lifecycle + +| Command | Description | Spec Page | Status | WXS205SDU | Notes | +|---------|------------------------------------------|-----------|--------|-----------|-------| +| E01 | Current system error state | 62 | HIGH | - | Not on WXS205SDU. | +| E02 | Weighing device errors and warnings | 63 | HIGH | - | Not on WXS205SDU. | +| E03 | Current system errors and warnings | 65 | HIGH | - | Not on WXS205SDU. | +| FSET | Reset all settings to factory defaults | 95 | LOW | yes | Level 3 on WXS205SDU. Destructive. | +| RO1 | Restart device | 221 | MED | - | | +| RDB | Readability | 222 | DONE | yes | request_readability(). Level 3 on WXS205SDU. | +| UPD | Update rate for SIR/SIRU | 267 | DONE | yes | request_update_rate() (read). set commented out (persists to memory). | +| USTB | User defined stability criteria | 268 | DONE | yes | request_stability_criteria() (read). set commented out. Level 3 on WXS205SDU. | + +### Network (not relevant for serial) + +| Command | Description | Spec Page | Status | WXS205SDU | Notes | +|---------|------------------------------------------|-----------|--------|-----------|-------| +| I53 | IPv4 runtime network config | 123 | N/A | - | Ethernet only. | +| M69 | IPv4 network configuration mode | 193 | N/A | - | | +| M70 | IPv4 host address + netmask | 195 | N/A | - | | +| M71 | IPv4 default gateway | 197 | N/A | - | | +| M72 | IPv4 DNS server | 199 | N/A | - | | +| M109 | IPv4 managed network config | 204 | N/A | - | | +| M117 | TCP port number | 209 | N/A | - | | +| M118 | Fieldbus network stack type | 211 | N/A | - | | +| NID | Node identification | 218 | N/A | - | | +| NID2 | Device node ID | 219 | N/A | - | | + +### Application-Specific (Level 3, filling/dosing) + +| Command | Description | Spec Page | Status | WXS205SDU | Notes | +|---------|------------------------------------------|-----------|--------|-----------|-------| +| A01 | Percent weighing reference | 17 | N/A | - | Application mode. | +| A02 | Sample identification | 18 | N/A | - | | +| A03 | Sample name | 19 | N/A | - | | +| A06 | Dynamic weighing behavior | 20 | N/A | - | | +| A10 | Nominal, +Tolerance, -Tolerance | 21 | N/A | - | | +| A30 | Internal loads | 22 | N/A | - | | +| CW02 | Time for weighing | 48 | N/A | - | | +| CW03 | Triggered weight value | 50 | N/A | - | | +| CW11 | Check weighing: weight calculation mode | 51 | N/A | - | | +| F01-F16 | Filling functions (16 commands) | 69-91 | N/A | - | Filling/dosing application. | +| FCUT | Filter cut-off frequency | 92 | DONE | yes | request_filter_cutoff() (read). set commented out. Level 3 on WXS205SDU. | +| FCUT2 | Alt weight path cut-off frequency | 93 | N/A | - | | +| WMCF | Weight monitoring functions | 270 | N/A | - | | +| M17 | ProFACT: Single time criteria | 160 | DONE | yes | request_profact_time_criteria() (read). set commented out. Level 2 on WXS205SDU. | +| M18 | ProFACT/FACT: Temperature criterion | 162 | DONE | yes | request_profact_temperature_criterion() (read). set commented out. Level 2 on WXS205SDU. | +| M22 | Custom unit definitions | 168 | N/A | - | | +| M31 | Operating mode after restart | 174 | DONE | yes | request_operating_mode() (read). set commented out. Level 2 on WXS205SDU. | +| M32 | ProFACT: Time criteria | 175 | DONE | yes | request_profact_time() (read). set commented out. Level 2 on WXS205SDU. | +| M33 | ProFACT: Day of the week | 176 | DONE | yes | request_profact_day() (read). set commented out. Level 2 on WXS205SDU. | +| M34 | MinWeigh: Method | 177 | N/A | - | | +| M38 | Selective parameter reset | 179 | N/A | - | | +| M39 | SmartTrac: Graphic | 180 | N/A | - | | +| M43 | Custom unit | 181 | N/A | - | | +| M44 | Command after startup response | 182 | N/A | - | | +| M45 | RS422/485 line termination | 183 | N/A | - | | +| M47 | Frequently changed test weight settings | 184 | N/A | - | | +| M48 | Infrequently changed test weight settings| 186 | N/A | - | | +| M66 | GWP: Certified test weight settings | 189 | N/A | - | | +| M89 | Interface command set | 201 | N/A | - | | +| M103 | RS422/485 driver mode | 202 | N/A | - | | +| M110 | Change display resolution | 205 | N/A | - | | +| M111 | SAI Cyclic data format | 207 | N/A | - | | +| M116 | Ignore Ethernet initial parametrization | 208 | N/A | - | | +| M119 | Byte order mode for automation | 212 | N/A | - | | +| M124 | Power supply for daisy chain | 214 | N/A | - | | +| MOD | Various user modes | 215 | N/A | - | | +| MONH | Monitor on interface | 217 | N/A | - | | +| SNRU | Stable weight (display unit) + repeat | 243 | N/A | - | | + +## Implementation Summary + +The MT-SICS spec defines **194 commands** (counting F01-F16 as 16 individual commands). + +| Category | Count | Description | +|----------|-------|-------------| +| Driver (active) | 54 | Implemented and callable | +| Driver (commented out) | 27 | Set/write counterparts and physical interaction commands | +| Not implemented | 113 | Not available on WXS205SDU or not applicable | + +### WXS205SDU coverage + +The WXS205SDU reports **62 commands** via I0. Of these: + +| State | Count | +|-------|-------| +| Active in driver | 49 | +| Commented out (physical/write) | 7 | +| Not implemented (streaming) | 2 (SIR, SR) | +| Undocumented (not in spec) | 4 (I22-I25) | + +### Expanding to other devices + +The remaining ~113 unimplemented spec commands (HIGH/MED/LOW/N/A in the table above) +are not available on the WXS205SDU and could not be validated. Integrating them +requires a developer with physical access to a device that supports the command, +to validate the response format. The pattern is: + +1. Confirm the command appears in the device's I0 list +2. Send the command and observe the raw response +3. Add a method to `driver.py` (the command check happens in `send_command`) +4. Add a test to `driver_tests.py` + +### Remaining priorities + +**HIGH (not available on WXS205SDU):** +- E01/E02/E03 (error monitoring) +- SIX1 (gross, net, tare in one call) + +**MED (useful but not urgent):** +- SIR/SR (continuous streaming) - needs async iterator architecture +- DATI (date + time combined) - not on WXS205SDU + +**STUB (commented out, require physical interaction):** +- C1/C3 (internal weight adjustment) +- C2 (external weight adjustment) +- TST1-TST3 (test procedures) diff --git a/pylabrobot/mettler_toledo/scales/protocol.md b/pylabrobot/mettler_toledo/scales/protocol.md new file mode 100644 index 00000000000..78cbeceab6f --- /dev/null +++ b/pylabrobot/mettler_toledo/scales/protocol.md @@ -0,0 +1,185 @@ +# Protocol: MT-SICS (Mettler Toledo Standard Interface Command Set) + +## Overview + +| Property | Value | +|----------|-------| +| Protocol name | MT-SICS (Mettler Toledo Standard Interface Command Set) | +| Transport | Serial (RS-232) via USB-to-serial adapter | +| Encoding | ASCII text | +| Baud rate | 9600 | +| Line terminator | CR LF (`\r\n`, 0x0D 0x0A) | +| Direction | Half-duplex (send command, wait for response) | +| Spec document | [MT-SICS Reference Manual](https://web.archive.org/web/20240208213802/https://www.mt.com/dam/product_organizations/industry/apw/generic/11781363_N_MAN_RM_MT-SICS_APW_en.pdf) | + +## Command format (PLR to device) + +``` + [ ...] CR LF +``` + +- Commands are uppercase ASCII +- Parameters separated by spaces +- Quoted strings use `"text"` +- Each command must be followed by CR LF + +Examples: +``` +S\r\n -- read stable weight +ZI\r\n -- zero immediately +M21 0 0\r\n -- set host unit to grams +D "Hello"\r\n -- write text to display +``` + +## Response format (device to PLR) + +### Standard response (single line) + +``` + [ ...] [] CR LF +``` + +The response echoes the command name, followed by a status character, optional data fields, and an optional unit. + +### Status codes + +| Status | Meaning | +|--------|---------| +| `A` | Command executed successfully (final response) | +| `B` | Command not yet terminated, additional responses follow | +| `S` | Stable weight value | +| `D` | Dynamic (unstable) weight value | +| `I` | Command understood but not executable (device busy) | +| `L` | Logical error (parameter not allowed) | +| `+` | Overload (weighing range exceeded) | +| `-` | Underload (weighing pan not in place) | + +### Error responses (no status field) + +``` +ES CR LF -- syntax error (command not recognized) +ET CR LF -- transmission error (parity/break) +EL CR LF -- logical error (command cannot execute) +``` + +These are 2-character responses with no status field or data. + +### Weight response errors + +``` +S S Error CR LF +``` + +The weight value field is replaced with an error code when the device detects a hardware fault. See spec Section 2.1.3.3. + +## Multi-response commands + +Commands that return status `B` send multiple lines. The final line has status `A`. + +Example - I50 (remaining weighing ranges): +``` +PLR sends: I50\r\n +Device sends: I50 B 0 535.141 g\r\n -- RangeNo 0, more lines follow + I50 B 1 -18.973 g\r\n -- RangeNo 1, more lines follow + I50 A 2 335.465 g\r\n -- RangeNo 2, final response +``` + +Example - C (cancel all): +``` +PLR sends: C\r\n +Device sends: C B\r\n -- cancel started + C A\r\n -- cancel complete +``` + +`send_command()` reads all lines until it sees status `A` (or non-`B`). + +## Exceptions to the standard format + +### @ (reset) response echoes I4, not @ + +``` +PLR sends: @\r\n +Device sends: I4 A "B207696838"\r\n +``` + +The @ command resets the device to its power-on state and responds with the serial number using the I4 response format, not the @ command name. + +### Commands not supported on WXS205SDU (bridge mode) + +The following commands return `ES` (syntax error) on the WXS205SDU WXA-Bridge +because they are not in the device's I0 command list. They may work on other +MT-SICS devices or on the same model with a terminal attached. + +- `C` (cancel all), `SC` (timed read), `ZC` (timed zero), `TC` (timed tare) +- `D`, `DW` (display commands - no terminal in bridge mode) +- `I50` (remaining weighing range) + +### I2 response format + +The I2 response packs type, capacity, and unit into a single quoted string: +``` +I2 A "WXS205SDU WXA-Bridge 220.00900 g" +``` +The device type can contain spaces. Parse from the right: unit is the last +token, capacity is second-to-last, type is everything before. +`shlex.split` is used to handle quoted strings correctly. + +### I15 uptime is in minutes + +I15 returns uptime in minutes since last start or restart, with +/- 5% accuracy. +Response: `I15 A `. Example: `I15 A 123014` = ~85 days. + +## Command discovery + +**I0 is the definitive source of command support**, not I1. + +I1 reports which standardized level sets are fully implemented. However, a device +can have individual commands from levels it does not fully support. The WXS205SDU +reports I1 levels [0, 1] but I0 discovers 62 commands across levels 0-3, including +M21, M28, and many other Level 2 commands. + +During `setup()`, the driver queries I0 to discover all available commands. +`send_command` checks the command against this list and raises if unsupported. + +## Command levels + +MT-SICS commands are grouped into levels. I1 reports level compliance but I0 is +the authoritative list of implemented commands. + +| Level | Description | Availability | +|-------|-------------|-------------| +| 0 | Basic set: identification, weighing, zero, tare, reset (@) | Always available | +| 1 | Elementary: tare memory, timed commands, repeat | Always available | +| 2 | Extended: configuration, device info, diagnostics | Model-dependent | +| 3 | Application-specific: filling, dosing, calibration | Model-dependent | + +## Date/time response format + +DAT and TIM return space-separated fields, not a single string: +``` +DAT A -- e.g. DAT A 01 10 2021 = 1 Oct 2021 +TIM A -- e.g. TIM A 09 56 11 = 09:56:11 +``` + +Both support set variants (`DAT DD MM YYYY`, `TIM HH MM SS`). +DAT set persists only via MT-SICS or FSET, not @. +TIM set also persists; only reset via MT-SICS, FSET, or terminal menu, not @. + +## Write safety + +Commands that modify device settings (M01 set, M02 set, M03 set, etc.) persist +to memory and survive power cycles. They cannot be undone with @ reset - only +via FSET (factory reset) or the terminal menu. Write methods are commented out +in the driver to prevent accidental modification. + +Exceptions: `set_date()`, `set_time()`, and `set_device_id()` are active (not +commented out) since they do not change weighing behaviour. + +## Interrupt safety + +When a command is interrupted (KeyboardInterrupt or asyncio.CancelledError), +`send_command` sends `C` (cancel all) if the device supports it, otherwise just +flushes the serial buffer. Device state (zero, tare) is never cleared by an +interrupt. See the interrupt-safe command layer pattern. + +See `mt_sics_commands.md` for the full command reference with implementation status.