Unbricking Ocean Optics Spectrometers


Unbricking HR4000 with a Raspberry Pi Unbricking HR4000 with a Raspberry Pi, using a breakout cable

Problem statement

During my Ocean Optics USB-DT adventures and the follow-up write-up of the I²C bug I managed to brick both HR4000 and USB4000 spectrometers1.

This article explains how to flash firmware into an Ocean Optics spectrometer that doesn’t respond well to the official USB Devices: Recovering a Corrupted EEPROM procedure2.

This was tested on HR4000, USB4000, and partially on Flame-S3. It is almost certain it would work for USB2000 or USB2000+. If you have another model (especially newer ones), you’ll need to use your judgement whether it is feasible or not.

Overview

The plan is simple:

  1. Reliably connect to I²C bus
  2. Use a few lines of Python to backup (and then flash) the EEPROM

To perform this miracle healing, you will need:

Note: The reason the following procedure is safe is because after the initial bootstrap (configuration of the Cypress FX2 from the EEPROM and accessory + temp chip initialization), there’s no traffic on the I²C bus. That is, unless you trigger it by some command via USB / serial. If there were constant traffic, you couldn’t do this (safely).

Let’s get physical

In order to do the rest, you need reliable connection to the I²C bus of the spectrometer.

Depending on the model, this is either:

… without opening the device (or using a breakout board for the “accessory” connector).

Why am I saying that?

The USB2000+ and USB4000 uses Samtec IPT1-111-01-S-D-RA connector, which has a rather wide pitch (2.54mm).

The HR4000 uses 3M Pak50 (P50-030P1-RR1-TG), which has 1.27mm pitch.

The Flame-S uses JAE DD4 with 0.4mm pitch, that is additionally marked “obsolete”, and thus unobtainium from normal distributors5.

And while you can quite easily interface with 2.54mm pitch (I’ll show you how), targeting the 0.4mm in a recessed DD4 is not something you’d want to do as long as you have other options.

So, let’s talk connection options for each.

USB4000 (and USB2000+)

As mentioned before, USB4000 (datasheet) and USB2000+ (datasheet) use Samtec IPT1-111-01-S-D-RA.

The USB2000+ / USB4000 devices have the following pinout:

usb spectrometer pinout USB4000 / USB2000+ pinout

And given the pitch and size of the Samtec IPT1, it’s possible to strip down the female Dupont (jumper) wire from its sleeve and use that to connect:

usb4000 dupont I²C connection USB4000 dupont I²C connection: brown GND, red SDA, orange SCL

As far as I can tell, it doesn’t leave permanent marks, it is reliable enough to read out and write the EEPROM, and is secure enough to withstand normal manipulation.

Obviously, if you’re uncomfortable with that approach (especially the part where it’s powered on via USB), you can always open up the device, and connect to the EEPROM directly.

Unfortunately for us, in USB4000 the 24LC256 EEPROMs are on the PCB side that is facing optics.

If you open the USB4000, you get to see the following:

usb4000 with the lid off USB4000 with the lid off

If you look closely, though, you will find the following U3 fellow close to the USB port (labeled 1721):

usb4000 U3 DS1721 USB4000: close-up of the U3 (DS1721)

That’s a DS1721 digital thermometer chip.

So if you’re squeamish about connecting to the header with the Dupont hack6, and don’t want to unscrew the PCB (because that might drift the optical alignment), you could use the following pins of the DS17217:

HR4000

The HR4000 (datasheet) has the following pinout:

HR4000 spectrometer pinout HR4000 pinout

Connecting to HR4000 is very easy with the breakout cable I described in the USB-DT post (and pictured above in the hero image). But I assume you don’t have that cable (or P50-030S-EA or similar part) just lying around.

In that case, you could try and interface with the connector using PCBite:

HR4000 via PCBite HR4000 via PCBite

If that doesn’t float your boat, then opening it up is actually good second option:

HR4000 without lid HR4000 without lid

On the top left side you find the two 24LC256 EEPROMs (datasheet):

HR4000 24LC256 HR4000: close-up of the 24LC256

Connecting to either of them will do the trick; relevant pins:

Flame-S

Actually, here you’re on your own.

The FLAME-S datasheet / manual lists the DD4 pins as:

but I have no need to open my Flame up, and I couldn’t find any pictures of the PCB.

So, either you have a breakout, or best luck on the inside8

After opening it, search for DS1721 or 24LC256 on the board, and connect to them in a similar fashion described above.

And with that, let’s talk about the other side.

The other side (of the connection)

In the beginning I alluded to the fact you need a Raspberry Pi (or any other I²C capable device).

If you get a Raspberry Pi, the following pins are of interest:

RaspberryPi GPIO pins (I²C) Raspberry Pi GPIO pins (relevant to I²C), from RPi documentation, Raspberry Pi Ltd, used under CC-BY-SA

To cut the prose short, you connect GND-GND, plug in the spectrometer to USB (can be the USB on the Raspi), then you connect SCL-SCL, and finally SDA-SDA.

If you’ve done that, then the last step is the actual dump / flash.

Software

Depending on what OS you run on the Raspberry, you might need to enable I²C. If you have Raspi OS (and thus raspi-config utility), then Options → I2C → Yes. Followed by reboot. If you’re a bit more low-level (e.g. you use Alpine Linux like I do), you need dtparam=i2c_arm=on in your usercfg.txt9, then a reboot.

Afterwards make sure the appropriate kernel modules are loaded:

modprobe i2c-dev
modprobe i2c-bcm2835

After which you need tools and libraries:

# Raspi OS
sudo apt install -y i2c-tools python3-smbus
sudo pip3 install smbus2

# Alpine Linux
apk add i2c-tools py3-smbus py3-pip
pip install smbus2 --break-system-packages

With that out of the way, time to probe the I²C bus:

$ i2cdetect -y 1
     0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f
00:                         -- -- -- -- -- -- -- --
10: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
20: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
30: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
40: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 4f
50: -- 51 -- 53 -- -- -- -- -- -- -- -- -- -- -- --
60: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
70: -- -- -- -- -- -- -- --

You should be getting at least 0x4f (DS1721 temperature sensor) and 0x51 (24LC256 with firmware and EEPROM slots). The 0x53 is another 24LC256 that holds the irrad_cal data.

Otherwise, check your wiring.

If you got this far, you’re almost home.

I give thee two python scripts:

An EEPROM dumper:

eeprom_dump.py (click to expand)

#!/usr/bin/env python3
"""Dump a 24LC256 I2C EEPROM (32KB, 15-bit addressing) to a binary file."""

import argparse
import sys

from smbus2 import SMBus, i2c_msg

EEPROM_SIZE = 32768  # 24LC256: 256Kbit = 32KB
READ_CHUNK = 64


def dump_eeprom(bus_num: int, addr: int, size: int, chunk: int) -> bytes:
    data = bytearray()
    with SMBus(bus_num) as bus:
        for offset in range(0, size, chunk):
            n = min(chunk, size - offset)
            addr_hi = (offset >> 8) & 0xFF
            addr_lo = offset & 0xFF
            write = i2c_msg.write(addr, [addr_hi, addr_lo])
            read = i2c_msg.read(addr, n)
            bus.i2c_rdwr(write, read)
            data.extend(bytes(read))
    return bytes(data)


def main() -> None:
    parser = argparse.ArgumentParser(description="Dump a 24LC256 I2C EEPROM to a file")
    parser.add_argument("output", help="output binary file")
    parser.add_argument("--bus", type=int, default=1, help="I2C bus number (default: 1)")
    parser.add_argument(
        "--addr",
        type=lambda x: int(x, 0),
        default=0x50,
        help="7-bit I2C address (default: 0x50)",
    )
    parser.add_argument(
        "--size",
        type=lambda x: int(x, 0),
        default=EEPROM_SIZE,
        help="bytes to dump (default: 32768)",
    )
    parser.add_argument(
        "--chunk",
        type=int,
        default=READ_CHUNK,
        help="bytes per I2C transaction (default: 64)",
    )
    args = parser.parse_args()

    try:
        data = dump_eeprom(args.bus, args.addr, args.size, args.chunk)
    except OSError as e:
        print(f"I2C error: {e}", file=sys.stderr)
        sys.exit(1)

    with open(args.output, "wb") as f:
        f.write(data)

    print(f"Wrote {len(data)} bytes to {args.output}")


if __name__ == "__main__":
    main()

and an EEPROM flasher:

eeprom_flash.py (click to expand)

#!/usr/bin/env python3
"""Flash a 24LC256 I2C EEPROM (32KB, 15-bit addressing, 64-byte pages)
from a binary file, with post-write verification."""

import argparse
import sys
import time

from smbus2 import SMBus, i2c_msg

EEPROM_SIZE = 32768
PAGE_SIZE = 64
WRITE_TIMEOUT = 0.1  # seconds to poll for write cycle completion


def wait_write_complete(bus: SMBus, addr: int, timeout: float = WRITE_TIMEOUT) -> None:
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        try:
            bus.i2c_rdwr(i2c_msg.write(addr, []))  # ACK poll: NAKs until write cycle done
            return
        except OSError:
            time.sleep(0.0005)
    raise OSError(f"EEPROM write did not complete within {timeout}s")


def write_page(bus: SMBus, addr: int, offset: int, data: bytes) -> None:
    addr_hi = (offset >> 8) & 0xFF
    addr_lo = offset & 0xFF
    msg = i2c_msg.write(addr, [addr_hi, addr_lo] + list(data))
    bus.i2c_rdwr(msg)
    wait_write_complete(bus, addr)


def read_bytes(bus: SMBus, addr: int, offset: int, n: int) -> bytes:
    addr_hi = (offset >> 8) & 0xFF
    addr_lo = offset & 0xFF
    write = i2c_msg.write(addr, [addr_hi, addr_lo])
    read = i2c_msg.read(addr, n)
    bus.i2c_rdwr(write, read)
    return bytes(read)


def flash_eeprom(bus_num: int, addr: int, data: bytes) -> None:
    with SMBus(bus_num) as bus:
        offset = 0
        total = len(data)
        while offset < total:
            page_remaining = PAGE_SIZE - (offset % PAGE_SIZE)
            n = min(page_remaining, total - offset)
            write_page(bus, addr, offset, data[offset:offset + n])
            offset += n


def verify_eeprom(bus_num: int, addr: int, data: bytes, chunk: int = 64) -> bool:
    with SMBus(bus_num) as bus:
        offset = 0
        total = len(data)
        while offset < total:
            n = min(chunk, total - offset)
            read_back = read_bytes(bus, addr, offset, n)
            expected = data[offset:offset + n]
            if read_back != expected:
                i = next(j for j in range(n) if read_back[j] != expected[j])
                print(
                    f"Mismatch at offset 0x{offset + i:04X}: "
                    f"wrote 0x{expected[i]:02X}, read 0x{read_back[i]:02X}",
                    file=sys.stderr,
                )
                return False
            offset += n
    return True


def main() -> None:
    parser = argparse.ArgumentParser(
        description="Flash a 24LC256 I2C EEPROM from a binary file, with verification"
    )
    parser.add_argument("input", help="input binary file")
    parser.add_argument("--bus", type=int, default=1, help="I2C bus number (default: 1)")
    parser.add_argument(
        "--addr",
        type=lambda x: int(x, 0),
        default=0x50,
        help="7-bit I2C address (default: 0x50)",
    )
    parser.add_argument(
        "--no-verify", action="store_true", help="skip post-write verification"
    )
    args = parser.parse_args()

    with open(args.input, "rb") as f:
        data = f.read()

    if len(data) > EEPROM_SIZE:
        print(
            f"Error: input is {len(data)} bytes, EEPROM is {EEPROM_SIZE} bytes",
            file=sys.stderr,
        )
        sys.exit(1)

    try:
        flash_eeprom(args.bus, args.addr, data)
    except OSError as e:
        print(f"I2C error during write: {e}", file=sys.stderr)
        sys.exit(1)

    print(f"Wrote {len(data)} bytes")

    if not args.no_verify:
        try:
            ok = verify_eeprom(args.bus, args.addr, data)
        except OSError as e:
            print(f"I2C error during verify: {e}", file=sys.stderr)
            sys.exit(1)
        if not ok:
            print("Verification FAILED", file=sys.stderr)
            sys.exit(1)
        print("Verification OK")


if __name__ == "__main__":
    main()

They are used exactly as you would expect:

# Dump the 0x51 (firmware + EEPROM slots)
$ python eeprom_dump.py --addr 0x51 0x51.bin
Wrote 32768 bytes to 0x51.bin

# Dump the 0x53 (irrad_cal)
$ python eeprom_dump.py --addr 0x53 0x53.bin
Wrote 32768 bytes to 0x53.bin

# Flash the proper firmware
$ python eeprom_flash.py --addr 0x51 HR4000v2230nonAChip.iic
Wrote 17203 bytes
Verification OK

Not much to it, in the end10. But please: first backup whatever was in the EEPROM, only then flash. You never know if it proves useful down the road. And the file is laughably small.

Closing words

I think the trickiest part of this whole adventure is the physical setup. The pins are small, and thus easy to fat finger a disaster.

With a bit of magnification, and proper EEPROM dump first, there isn’t that much to go wrong.

  1. Insert “because we’re smart” meme here.

  2. Whose tldr is: short the SDA pin, plug in the spectrometer, use OO’s “USB EEPROM Programmer” utility.

  3. Where I just verified the I²C addresses are the same; using another (unpublished) script that dumps data using the seabreeze raw_usb_bus_access feature (with 0x60 and 0x61 commands).

  4. There are budget options; see your favorite Chinesium merchant.

  5. Neither Mouser nor Digikey have them; some Chinese suppliers would love to sell you some, at $25-$90 per connector (quoted at 10 pc qty).

  6. But if you are, explain yourself…

  7. I haven’t tried supplying power via the pin 8 (VDD), but maybe that would also power the 24LC256 on the other side of the PCB?

  8. And if you do open it up, send me a few pictures, will you?

  9. /media/mmcblk0p1/usercfg.txt to be more precise

  10. If you’re wondering about the nonAChip vs AChip for HR4000 firmware, I actually ran diff -u <(xxd 0x51.bin) <(xxd HR4000v2230nonAChip.iic) and determined, that nonA is mine, because the diff was smaller than in the A case. Highly scientific™.