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 +46 -0
- brick/eeprom.py +167 -0
- brick/errors.py +19 -0
- brick/mainframe.py +616 -0
- brick/modules/__init__.py +50 -0
- brick/modules/acresistancebridge.py +243 -0
- brick/modules/base.py +186 -0
- brick/modules/diodereadout.py +229 -0
- brick/modules/isolatedvoltagesource.py +87 -0
- brick/modules/unprovisioned.py +88 -0
- brick_instruments-0.9.0.dist-info/METADATA +239 -0
- brick_instruments-0.9.0.dist-info/RECORD +14 -0
- brick_instruments-0.9.0.dist-info/WHEEL +4 -0
- brick_instruments-0.9.0.dist-info/licenses/LICENSE +674 -0
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."""
|