Source code for pyjoulescope_driver.mem_client

# SPDX-FileCopyrightText: Copyright 2026 Jetperch LLC
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Generic mb_stdmsg_mem_s protocol client."""

import struct
from .stdmsg import StdMsg


FLASH_PAGE_SIZE = 256
FLASH_BLOCK_64K = 65536

# mb_stdmsg_mem_op_e
MEM_OP_READ = 1
MEM_OP_WRITE = 2
MEM_OP_ERASE = 3

MB_STDMSG_MEM = 0x07
PERSONALITY_TARGET = 2
PERSONALITY_PUBLIC_SIZE = 76
_PERSONALITY_HDR = struct.Struct('<BBHHBBqq')

# mb_stdmsg_header_s: version(u16), type(u8), origin_prefix(u8),
#   metadata(u32)
STDMSG_HDR = struct.Struct('<HBBI')

# mb_stdmsg_mem_s: transaction_id(u32), target(u8), operation(u8),
#   flags(u8), status(u8), timeout_ms(u16), rsv1_u16(u16),
#   offset(u32), length(u32), delay_us(u32)
MEM_CMD_HDR = struct.Struct('<I4B2H3I')


[docs] class MemClient: """Generic mb_stdmsg_mem_s protocol client. :param driver: The Driver instance. :param device_path: The device path (e.g. 'u/js320/000000'). :param cmd_topic: The memory command sub-topic (e.g. 's/flash/!cmd'). :param target: The target memory region ID (0-255). """ def __init__(self, driver, device_path, cmd_topic, target=0): self._driver = driver self._device_path = device_path self._cmd_topic = cmd_topic self._target = int(target) & 0xFF self._txn_id = 0 def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): return False @property def rsp_topic(self): return f'{self._device_path}/h/!rsp' @property def pub_topic(self): return f'{self._device_path}/{self._cmd_topic}'
[docs] def cmd(self, op, offset=0, length=0, data=b'', timeout_ms=5000, target=None): """Send a memory command and wait for the response. :param op: The memory operation (MEM_OP_READ, etc.). :param offset: The byte offset within the target region. :param length: The operation length in bytes. :param data: The payload data for write operations. :param timeout_ms: The device-side timeout in milliseconds. :param target: Override the instance target for this call. :return: The response data bytes. :raises TimeoutError: If no response within timeout. :raises RuntimeError: If the device returns a non-zero status. """ if target is None: target = self._target else: target = int(target) & 0xFF self._txn_id = (self._txn_id + 1) & 0xFFFF_FFFF txn_id = self._txn_id stdmsg_hdr = STDMSG_HDR.pack(0, MB_STDMSG_MEM, ord('h'), 0) mem_hdr = MEM_CMD_HDR.pack( txn_id, target, op, 0, 0, timeout_ms, 0, offset, length, 0) rsp = self._driver.publish_and_wait( self.pub_topic, StdMsg(stdmsg_hdr + mem_hdr + data), self.rsp_topic, timeout=(timeout_ms / 1000) + 5, match=lambda r: r.get('transaction_id') == txn_id) status = rsp['status'] if status != 0: raise RuntimeError( f'mem op 0x{op:02x} failed ' f'(status={status}, {self._cmd_topic})') return rsp['data']
[docs] def erase_64k(self, offset): """Erase a 64 KB block. :param offset: The block-aligned byte offset. """ self.cmd(MEM_OP_ERASE, offset=offset, length=FLASH_BLOCK_64K, timeout_ms=5000)
[docs] def write_pages(self, offset, data): """Write data in 256-byte page chunks. :param offset: The starting byte offset. :param data: The bytes to write. """ for i in range(0, len(data), FLASH_PAGE_SIZE): chunk = data[i:i + FLASH_PAGE_SIZE] if len(chunk) < FLASH_PAGE_SIZE: chunk = chunk + b'\xff' * (FLASH_PAGE_SIZE - len(chunk)) self.cmd(MEM_OP_WRITE, offset=offset + i, length=FLASH_PAGE_SIZE, data=chunk, timeout_ms=1000)
[docs] def read(self, offset, length): """Read data in 256-byte page chunks. :param offset: The starting byte offset. :param length: The number of bytes to read. :return: The read bytes. """ result = bytearray() for i in range(0, length, FLASH_PAGE_SIZE): chunk_len = min(FLASH_PAGE_SIZE, length - i) data = self.cmd(MEM_OP_READ, offset=offset + i, length=chunk_len, timeout_ms=1000) result.extend(data[:chunk_len]) return bytes(result)
[docs] def write_verify(self, offset, data, retries=3): """Erase, write, and verify with retries. :param offset: The flash byte offset. :param data: The data to write. :param retries: Number of attempts before raising. :raises RuntimeError: If verification fails after all retries. """ for attempt in range(retries): self.erase_64k(offset & 0xFFFF_0000) self.write_pages(offset, data) readback = self.read(offset, len(data)) if readback == data: return raise RuntimeError( f'write/verify failed at 0x{offset:06x} ' f'after {retries} attempts')
[docs] def write_verify_records(self, records, retries=3): """Erase, write, and verify multiple records with retries. :param records: The list of (offset, data) pairs. :param retries: Number of attempts before raising. :raises RuntimeError: If verification fails after all retries. """ for attempt in range(retries): erased = {} rc = True for offset, data in records: erase_addr = offset & 0xFFFF_0000 if erase_addr not in erased: self.erase_64k(erase_addr) erased[erase_addr] = True self.write_pages(offset, data) readback = self.read(offset, len(data)) rc &= readback == data if rc: return raise RuntimeError( f'write/verify failed after {retries} attempts')
[docs] def read_personality(self): """Read the public mb_personality_app_s from the sys task. The MemClient must be pointed at the sys task command topic (e.g. ``MemClient(driver, device, 'c/sys/!cmd')``). :return: A dict with the public personality fields. :raises ValueError: If the response is too short. """ data = self.cmd(MEM_OP_READ, offset=0, length=PERSONALITY_PUBLIC_SIZE, target=PERSONALITY_TARGET) if len(data) < PERSONALITY_PUBLIC_SIZE: raise ValueError( f'personality response too short: ' f'{len(data)} < {PERSONALITY_PUBLIC_SIZE}') (fmt_ver, _rsv, vendor_id, product_id, subtype, hw_ver, hw_opts, mfg_time ) = _PERSONALITY_HDR.unpack_from(data, 0) return { 'format_version': fmt_ver, 'vendor_id': vendor_id, 'product_id': product_id, 'subtype': subtype, 'hw_ver': hw_ver, 'hw_opts': hw_opts, 'mfg_time': mfg_time, 'mfg_info': data[24:56].rstrip(b'\x00').decode('utf-8'), 'serial_number': data[56:68].rstrip(b'\x00').decode('utf-8'), 'app': bytes(data[68:76]), }