brick-instruments 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
brick/__init__.py ADDED
@@ -0,0 +1,46 @@
1
+ """Host drivers for the Brick modular instrument system.
2
+
3
+ One mainframe owns one serial port and hands you an object per slot:
4
+
5
+ >>> from brick import Brick
6
+ >>> brick = Brick('/dev/cu.usbmodem101')
7
+ >>> brick.slots
8
+ {1: DiodeReadout(slot=1), 2: ACResistanceBridge(slot=2),
9
+ 3: IsolatedVoltageSource(slot=3), 4: None}
10
+
11
+ >>> vs = brick.slots[3]
12
+ >>> vs.set_voltage(0.47)
13
+ >>> vs.set_output(True)
14
+ >>> vs.get_calibration()
15
+
16
+ Everything a brick offers is a method, including the facts that never
17
+ change -- ``vs.slot()``, ``vs.type()`` -- so there is one way to call
18
+ anything and nothing invites an assignment that would silently do nothing.
19
+ ``brick.slots`` is the one attribute, because it is what you index, and it
20
+ is read-only.
21
+
22
+ A factory-fresh brick has a blank ID EEPROM, so it arrives as a placeholder
23
+ that says what to do:
24
+
25
+ >>> brick.slots[4]
26
+ Unprovisioned(slot=4) -- call .initialize(<type>); brick.types() lists them
27
+ >>> brick.slots[4].initialize('DiodeReadout')
28
+ DiodeReadout(slot=4)
29
+
30
+ Published as ``brick-instruments``, imported as ``brick``.
31
+ """
32
+
33
+ from .errors import BrickError, EepromError, SlotError
34
+ from .mainframe import Brick, Connection, _find_port, find_bricks
35
+ from .modules import (ACResistanceBridge, BrickModule, DiodeReadout,
36
+ IsolatedVoltageSource, UnknownType, Unprovisioned)
37
+
38
+ __version__ = '0.9.0'
39
+
40
+ __all__ = [
41
+ 'Brick', 'Connection', 'find_bricks',
42
+ 'BrickModule', 'DiodeReadout', 'ACResistanceBridge',
43
+ 'IsolatedVoltageSource', 'Unprovisioned', 'UnknownType',
44
+ 'BrickError', 'EepromError', 'SlotError',
45
+ '__version__',
46
+ ]
brick/eeprom.py ADDED
@@ -0,0 +1,167 @@
1
+ """The bricks' ID EEPROMs: record layout, CRC32 and chunked access.
2
+
3
+ Every brick carries a 24AA64 holding two records, each a 16-byte binary
4
+ frame followed by a UTF-8 JSON document:
5
+
6
+ record frame payload capacity holds
7
+ identity 0x0000 0x0010-0x03FF 1008 B type
8
+ data 0x0400 0x0410-0x1FFF 7152 B calibration, date, notes
9
+
10
+ This file mirrors the firmware's own ``mainframe/eeprom.py``. It is a copy,
11
+ and copies go stale, so :func:`write_json` compares the CRC the board
12
+ computed over what actually landed against the one computed here. That one
13
+ check catches both a dropped chunk and these offsets drifting from the
14
+ firmware's -- and it fails at write time rather than corrupting a record
15
+ that reads back wrong weeks later.
16
+
17
+ Everything here takes a ``brick`` whose ``_query(command, timeout=None)``
18
+ reaches the mainframe, so it needs no serial port of its own and can be
19
+ tested against a fake.
20
+ """
21
+
22
+ import binascii
23
+ import json
24
+ import time
25
+
26
+ from .errors import EepromError
27
+
28
+ __all__ = ['EEPROM_RECORDS', 'crc32', 'dump', 'write_bytes', 'read_json',
29
+ 'write_json', 'merge_calibration']
30
+
31
+ FRAME_SIZE = 16
32
+ EEPROM_RECORDS = {
33
+ 'identity': {'index': 0, 'frame': 0x0000, 'payload': 0x0010, 'limit': 0x0400},
34
+ 'data': {'index': 1, 'frame': 0x0400, 'payload': 0x0410, 'limit': 0x2000},
35
+ }
36
+
37
+ # One EEPROM:WRITE carries this many bytes. 32 matches the chip's page size,
38
+ # so each chunk is a single page write; the firmware refuses more than 64 per
39
+ # command because a longer line breaks MicroPython's own parser.
40
+ WRITE_CHUNK = 32
41
+
42
+ # EEPROM:DUMP replies are one line, and the firmware caps them at 256 bytes.
43
+ DUMP_CHUNK = 256
44
+
45
+ # EEPROM:ERASE:ALL writes all 256 pages, each waiting out its own write
46
+ # cycle: about 2 s, during which the board answers nothing else.
47
+ ERASE_ALL_TIMEOUT = 20.0
48
+
49
+
50
+ def capacity(record):
51
+ """Usable payload bytes in a record."""
52
+ entry = EEPROM_RECORDS[record]
53
+ return entry['limit'] - entry['payload']
54
+
55
+
56
+ def crc32(data):
57
+ return binascii.crc32(bytes(data)) & 0xFFFFFFFF
58
+
59
+
60
+ def frame(brick, slot, record='identity'):
61
+ """A record's frame: presence, length, CRC32 and any error."""
62
+ return brick._query_literal(f'EEPROM:FRAME? {slot} {record}')
63
+
64
+
65
+ def dump(brick, slot, offset=0, length=32):
66
+ """Raw bytes, in as many EEPROM:DUMP calls as it takes."""
67
+ result = bytearray()
68
+ while len(result) < length:
69
+ chunk = min(DUMP_CHUNK, length - len(result))
70
+ reply = brick._query(f'EEPROM:DUMP {slot} {offset + len(result)} {chunk}')
71
+ result += bytes(int(byte, 16) for byte in reply.split())
72
+ return bytes(result)
73
+
74
+
75
+ def write_bytes(brick, slot, offset, data):
76
+ """Write raw bytes, one EEPROM page per command."""
77
+ data = bytes(data)
78
+ written = 0
79
+ while written < len(data):
80
+ chunk = data[written:written + WRITE_CHUNK]
81
+ payload = ' '.join(f'{byte:02X}' for byte in chunk)
82
+ brick._query(f'EEPROM:WRITE {slot} {offset + written} {payload}')
83
+ written += len(chunk)
84
+ return written
85
+
86
+
87
+ def read_json(brick, slot, record='data'):
88
+ """Read and parse a record. Returns ``(document, frame)``.
89
+
90
+ ``document`` is None when the record is absent or corrupt, and the
91
+ frame says which.
92
+ """
93
+ header = frame(brick, slot, record)
94
+ if not header.get('valid'):
95
+ return None, header
96
+ payload = dump(brick, slot, EEPROM_RECORDS[record]['payload'],
97
+ header['length'])
98
+ if crc32(payload) != header['payload_crc']:
99
+ return None, dict(header, valid=False,
100
+ error='payload CRC32 mismatch on readback')
101
+ try:
102
+ return json.loads(payload.decode('utf-8')), header
103
+ except (ValueError, UnicodeError) as error:
104
+ return None, dict(header, valid=False,
105
+ error=f'payload is not valid JSON: {error}')
106
+
107
+
108
+ def write_json(brick, slot, document, record='data'):
109
+ """Serialise ``document`` here and store it in a brick's EEPROM.
110
+
111
+ Serialising on the host is deliberate. CircuitPython uses
112
+ single-precision floats and its ``json.dumps`` emits only about six
113
+ significant digits, so a board serialising its own calibration would
114
+ silently round it -- 123456.789012345 becomes 123457.0. Written from
115
+ here every digit is stored; the board then holds about seven
116
+ significant digits once parsed, which is 0.12 ppm on a 125 kohm
117
+ resistor.
118
+
119
+ The payload goes out in page-sized chunks, then EEPROM:WRITEFRAME has
120
+ the board compute the CRC over what actually landed. Comparing that
121
+ against the CRC computed here is the check described in the module
122
+ docstring.
123
+ """
124
+ if record not in EEPROM_RECORDS:
125
+ raise EepromError(f'unknown record {record!r}; records are '
126
+ + ', '.join(sorted(EEPROM_RECORDS)))
127
+ payload = json.dumps(document, separators=(',', ':')).encode('utf-8')
128
+ room = capacity(record)
129
+ if not 0 < len(payload) <= room:
130
+ raise EepromError(f'{len(payload)} bytes of JSON does not fit the '
131
+ f'{room} byte {record} record')
132
+ write_bytes(brick, slot, EEPROM_RECORDS[record]['payload'], payload)
133
+ header = brick._query_literal(
134
+ f'EEPROM:WRITEFRAME {slot} {record} {len(payload)}')
135
+ if not isinstance(header, dict) or not header.get('valid'):
136
+ raise EepromError(f'writing the {record} record frame failed: {header}')
137
+ expected = crc32(payload)
138
+ if header['payload_crc'] != expected:
139
+ raise EepromError(
140
+ f'the board computed CRC32 0x{header["payload_crc"]:08X} over '
141
+ f'what it stored, but the payload sent was 0x{expected:08X}. '
142
+ 'Either a chunk went missing, or these record offsets no longer '
143
+ "match the firmware's.")
144
+ return header
145
+
146
+
147
+ def merge_calibration(brick, slot, constants, notes=None, date=None):
148
+ """Merge constants into a brick's ``data`` record and return it.
149
+
150
+ Every record carries a ``date`` (YYYYMMDD) and a ``notes`` string,
151
+ because a constant whose provenance nobody wrote down is a constant
152
+ nobody can defend later. Both are set here rather than on the board
153
+ because they have to be: the RP2040 has no battery-backed clock.
154
+
155
+ Reads first and merges, so storing one constant does not discard
156
+ another, the notes, or a sensor curve kept alongside.
157
+ """
158
+ existing, _ = read_json(brick, slot, 'data')
159
+ document = existing if isinstance(existing, dict) else {}
160
+ merged = dict(document.get('calibration') or {})
161
+ merged.update(constants)
162
+ document['calibration'] = merged
163
+ document['date'] = int(time.strftime('%Y%m%d')) if date is None else int(date)
164
+ document['notes'] = (document.get('notes', '') if notes is None
165
+ else str(notes))
166
+ write_json(brick, slot, document, 'data')
167
+ return document
brick/errors.py ADDED
@@ -0,0 +1,19 @@
1
+ """The package's exceptions.
2
+
3
+ In their own module so that ``mainframe`` and ``modules`` can both raise
4
+ them without importing each other, which they otherwise would have to.
5
+ """
6
+
7
+ __all__ = ['BrickError', 'EepromError', 'SlotError']
8
+
9
+
10
+ class BrickError(Exception):
11
+ """A command failed, or the board said something unexpected."""
12
+
13
+
14
+ class EepromError(BrickError):
15
+ """A record could not be read or written."""
16
+
17
+
18
+ class SlotError(BrickError):
19
+ """A slot holds nothing usable, or not what was asked for."""