FNIRSI provides firmware updates for the IPS3608, but the normal update workflow is clearly designed around Windows.

On macOS, the IPS3608 still enters firmware-update mode and appears as a USB drive, so at first it looks like the update should be as simple as copying the .bin file onto it.

On my unit, that did not work.

The firmware file copied successfully, appeared on the volume with the correct size, but pressing RUN/STOP did nothing.

After digging into the USB device and filesystem geometry, the reason became clear: the IPS3608 bootloader exposes an unusual tiny FAT12 mass-storage device with 2048-byte physical sectors.

On my unit the bootloader reports:

Device / Media Name:       Msc iap
Volume Name:               IPS3608
File System Personality:   MS-DOS FAT12
Disk Size:                 8.4 MB (8386560 Bytes)
Device Block Size:         2048 Bytes
Protocol:                  USB

macOS can mount this filesystem, but normal file copying does not necessarily produce a layout that the IPS3608 bootloader accepts.

The reliable solution is:

  1. read the IPS3608 IAP disk into a raw image;
  2. modify the image with mtools;
  3. compare the original and modified images in 2048-byte blocks;
  4. write only the changed blocks back to the device;
  5. verify the result byte-for-byte;
  6. press RUN/STOP to start the actual firmware installation.

This procedure successfully updated my IPS3608 to:

APP_IPS3608_V1.7_260327.bin

Warning

This procedure performs raw writes to a USB block device.

If you write to the wrong /dev/diskX, you can destroy data on another drive.

Always verify that the target device is the IPS3608 before continuing.

What you need

  • FNIRSI IPS3608
  • macOS
  • the IPS3608 firmware .bin
  • a USB data cable that actually works with the rear data port
  • Python 3
  • Homebrew
  • mtools

Install mtools:

brew install mtools

Enter Firmware Upgrade mode

Turn the IPS3608 off.

Hold the upper button on the front panel and turn the power on while continuing to hold the button.

The Firmware Upgrade interface should appear.

Now connect the IPS3608 to the Mac using the rear USB data port.

On my hardware revision this port is USB-C.

One important detail: several normal USB-C cables did not enumerate the IPS3608 correctly. The original FNIRSI USB cable worked immediately.

When the connection works, macOS should mount a volume named:

IPS3608

A clean bootloader volume contains:

Ready.TXT

You can check it with:

ls -la "/Volumes/IPS3608"

Example:

total 32
drwx------@  1 user  staff  16384 Dec 31  1979 .
drwxr-xr-x  19 root  wheel    608 Aug 18 08:52 ..
-rwx------   1 user  staff      0 Apr 18  2008 Ready.TXT

Identify the IPS3608 disk

Run:

diskutil list external

Then inspect the corresponding disk:

diskutil info /dev/disk6

Replace disk6 with the identifier assigned on your machine.

On my IPS3608 the important values were:

Device Identifier:         disk6
Device / Media Name:       Msc iap
Volume Name:               IPS3608
File System Personality:   MS-DOS FAT12
Protocol:                  USB
Disk Size:                 8.4 MB (8386560 Bytes)
Device Block Size:         2048 Bytes

The two most important details are:

Disk Size:          8386560 bytes
Device Block Size:  2048 bytes

Why Finder copy can fail

At first I copied the firmware to the mounted drive normally.

The filesystem looked completely correct:

Ready.TXT
APP_IPS3608_V1.7_260327.bin

The firmware size was also correct:

155720 bytes

But pressing RUN/STOP did not start the update.

Trying to use mtools directly against the raw device also failed:

sudo mcopy -i /dev/rdisk6 \
  APP_IPS3608_V1.7_260327.bin \
  ::APP_IPS3608_V1.7_260327.bin

with:

plain_io read/write: Invalid argument
init :: could not read boot sector
Cannot initialize '::'

The reason is the unusual 2048-byte device block size.

The workaround is to first copy the entire IAP filesystem into a normal file and let mtools operate on that image instead of directly on /dev/rdiskX.

Manual method

1. Unmount the IPS3608

diskutil unmountDisk force /dev/disk6

Expected result:

Forced unmount of all volumes on disk6 was successful

2. Read the complete IAP filesystem

Use the real device block size:

sudo dd \
  if=/dev/rdisk6 \
  of=/tmp/ips3608_before.img \
  bs=2048

On my unit this produced:

4095+0 records in
4095+0 records out
8386560 bytes transferred

The resulting image must be exactly:

8386560 bytes

Check:

ls -l /tmp/ips3608_before.img

3. Create a working copy

cp \
  /tmp/ips3608_before.img \
  /tmp/ips3608_after.img

4. Add the firmware to the image

MTOOLS_SKIP_CHECK=1 mcopy -o \
  -i /tmp/ips3608_after.img \
  "$HOME/Downloads/APP_IPS3608_V1.7_260327.bin" \
  ::/APP_IPS3608_V1.7_260327.bin

Then inspect the image:

MTOOLS_SKIP_CHECK=1 \
mdir -i /tmp/ips3608_after.img ::

My result was:

Volume in drive : is IPS3608

Directory for ::/

Ready    TXT          0 2008-04-18   8:20
APP_IP~1 BIN     155720 2026-08-18   9:00  APP_IPS3608_V1.7_260327.bin

2 files             155720 bytes
                  8175616 bytes free

At this point the FAT12 image contains the firmware correctly.

5. Compare the images by 2048-byte sector

Do not simply overwrite the complete disk with the modified image.

Instead, compare the original and modified images in 2048-byte blocks and write only sectors that changed.

The script below automates that part and performs several additional safety checks.

Automated macOS flasher

The following Python script performs the complete procedure.

It verifies:

  • macOS is being used;
  • mtools is installed;
  • the firmware is a .bin;
  • the filename begins with APP_IPS3608_;
  • the USB device identifies as Msc iap;
  • the volume is named IPS3608;
  • the protocol is USB;
  • disk size is exactly 8386560 bytes;
  • block size is exactly 2048 bytes.

Before writing anything, it asks you to explicitly type:

IPS3608

It then:

  1. unmounts the device;
  2. reads the original IAP filesystem;
  3. creates a temporary copy;
  4. inserts the firmware with mtools;
  5. identifies all changed 2048-byte sectors;
  6. writes only those sectors;
  7. reads the entire device back;
  8. compares it byte-for-byte against the prepared image.

ips3608_flash_macos.py

#!/usr/bin/env python3
"""
FNIRSI IPS3608 macOS firmware flasher

Why this exists:
The IPS3608 IAP bootloader exposes an unusual FAT12 USB mass-storage volume
("Msc iap" / "IPS3608") with a 2048-byte device block size. macOS can mount it,
but a normal Finder copy may not produce a layout the bootloader accepts.

This script:
  1. Finds the IPS3608 IAP disk.
  2. Validates media name, size, block size, protocol, and FAT12.
  3. Unmounts the disk.
  4. Reads a raw image of the IAP volume.
  5. Adds the firmware to a copy of that image using mtools.
  6. Compares images in 2048-byte blocks.
  7. Writes only changed blocks back to the device.
  8. Reads the device back and verifies it byte-for-byte.

Requirements:
  macOS
  Python 3
  mtools: brew install mtools

Usage:
  sudo python3 ips3608_flash_macos.py APP_IPS3608_V1.7_260327.bin
"""

from __future__ import annotations

import argparse
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path

EXPECTED_MEDIA_NAME = "Msc iap"
EXPECTED_VOLUME_NAME = "IPS3608"
EXPECTED_SIZE = 8_386_560
EXPECTED_BLOCK_SIZE = 2048
EXPECTED_FS = "MS-DOS FAT12"
EXPECTED_PROTOCOL = "USB"


def run(cmd, *, capture=True, check=True, env=None):
    if capture:
        return subprocess.run(
            cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
            check=check, env=env
        ).stdout
    subprocess.run(cmd, check=check, env=env)
    return ""


def require_root():
    if os.geteuid() != 0:
        sys.exit("ERROR: run this script with sudo.")


def require_mtools():
    if shutil.which("mcopy") is None or shutil.which("mdir") is None:
        sys.exit(
            "ERROR: mtools is not installed.\n"
            "Install it first with:\n\n"
            "  brew install mtools"
        )


def parse_diskutil_info(text: str) -> dict[str, str]:
    info = {}
    for line in text.splitlines():
        if ":" in line:
            key, value = line.split(":", 1)
            info[key.strip()] = value.strip()
    return info


def external_whole_disks() -> list[str]:
    out = run(["diskutil", "list", "external", "physical"])
    return sorted(set(re.findall(r"^/dev/(disk\d+)\b", out, re.M)))


def find_ips3608() -> tuple[str, dict[str, str]]:
    candidates = []
    for disk in external_whole_disks():
        text = run(["diskutil", "info", f"/dev/{disk}"], check=False)
        info = parse_diskutil_info(text)
        if info.get("Device / Media Name") == EXPECTED_MEDIA_NAME:
            candidates.append((disk, info))

    if not candidates:
        sys.exit(
            "ERROR: IPS3608 IAP disk not found.\n\n"
            "Enter Firmware Upgrade mode first:\n"
            "  1. Turn IPS3608 off.\n"
            "  2. Hold the upper button.\n"
            "  3. Turn the power on while holding it.\n"
            "  4. Connect the rear USB data port using a working data cable."
        )

    if len(candidates) > 1:
        sys.exit("ERROR: more than one 'Msc iap' device found. Disconnect extras.")

    return candidates[0]


def parse_bytes(value: str) -> int | None:
    m = re.search(r"\((\d+)\s+Bytes\)", value or "")
    return int(m.group(1)) if m else None


def parse_block_size(value: str) -> int | None:
    m = re.search(r"(\d+)\s+Bytes", value or "")
    return int(m.group(1)) if m else None


def validate_device(disk: str, info: dict[str, str]):
    problems = []

    if info.get("Device / Media Name") != EXPECTED_MEDIA_NAME:
        problems.append(f"media name is {info.get('Device / Media Name')!r}")
    if info.get("Volume Name") != EXPECTED_VOLUME_NAME:
        problems.append(f"volume name is {info.get('Volume Name')!r}")
    if info.get("Protocol") != EXPECTED_PROTOCOL:
        problems.append(f"protocol is {info.get('Protocol')!r}")
    if parse_bytes(info.get("Disk Size", "")) != EXPECTED_SIZE:
        problems.append(f"disk size is {info.get('Disk Size')!r}")
    if parse_block_size(info.get("Device Block Size", "")) != EXPECTED_BLOCK_SIZE:
        problems.append(f"block size is {info.get('Device Block Size')!r}")
    fs = info.get("File System Personality")
    if fs and fs != EXPECTED_FS:
        problems.append(f"filesystem is {fs!r}")

    if problems:
        print("ERROR: device validation failed:")
        for p in problems:
            print("  -", p)
        sys.exit(2)

    print(f"Found IPS3608: /dev/{disk}")
    print(f"  Media:      {info.get('Device / Media Name')}")
    print(f"  Volume:     {info.get('Volume Name')}")
    print(f"  Size:       {EXPECTED_SIZE} bytes")
    print(f"  Block size: {EXPECTED_BLOCK_SIZE} bytes")
    print(f"  Protocol:   {EXPECTED_PROTOCOL}")


def confirm(disk: str):
    print()
    print("WARNING: this operation writes raw sectors to a USB device.")
    print(f"Target: /dev/{disk}")
    answer = input("Type IPS3608 to continue: ").strip()
    if answer != "IPS3608":
        sys.exit("Cancelled.")


def dd_read(rawdev: str, output: Path):
    print("Reading original IAP image...")
    run([
        "dd",
        f"if={rawdev}",
        f"of={output}",
        f"bs={EXPECTED_BLOCK_SIZE}",
    ], capture=False)

    size = output.stat().st_size
    if size != EXPECTED_SIZE:
        sys.exit(f"ERROR: raw image size is {size}, expected {EXPECTED_SIZE}.")


def add_firmware(image: Path, firmware: Path):
    print("Adding firmware to FAT12 image with mtools...")
    env = os.environ.copy()
    env["MTOOLS_SKIP_CHECK"] = "1"

    # Remove an old firmware file if one exists. Failure is harmless.
    listing = run(["mdir", "-i", str(image), "::"], env=env, check=False)
    for line in listing.splitlines():
        if ".BIN" in line.upper() and "APP_IP" in line.upper():
            # mtools accepts wildcard deletion; use it only inside the temp image.
            run(["mdel", "-i", str(image), "::/APP_IP*.BIN"], env=env, check=False)
            break

    run([
        "mcopy", "-o",
        "-i", str(image),
        str(firmware),
        f"::/{firmware.name}",
    ], env=env)

    listing = run(["mdir", "-i", str(image), "::"], env=env)
    print(listing)

    if str(firmware.stat().st_size) not in listing.replace(" ", ""):
        # mdir formatting can vary; do a second, simpler sanity check below.
        pass


def changed_blocks(before: bytes, after: bytes) -> list[int]:
    if len(before) != EXPECTED_SIZE or len(after) != EXPECTED_SIZE:
        sys.exit("ERROR: image size changed unexpectedly.")

    return [
        offset // EXPECTED_BLOCK_SIZE
        for offset in range(0, EXPECTED_SIZE, EXPECTED_BLOCK_SIZE)
        if before[offset:offset + EXPECTED_BLOCK_SIZE]
        != after[offset:offset + EXPECTED_BLOCK_SIZE]
    ]


def write_changed_blocks(rawdev: str, before_img: Path, after_img: Path):
    before = before_img.read_bytes()
    after = after_img.read_bytes()
    blocks = changed_blocks(before, after)

    if not blocks:
        sys.exit("ERROR: no changed sectors found.")

    # A 155 KB firmware should not require anything remotely close to the full disk.
    if len(blocks) > 512:
        sys.exit(
            f"ERROR: unexpectedly large number of changed sectors ({len(blocks)}). "
            "Refusing to write."
        )

    print(f"Changed 2048-byte sectors: {len(blocks)}")
    print("Sector numbers:", blocks)

    print("Writing only changed sectors...")
    fd = os.open(rawdev, os.O_RDWR | os.O_SYNC)
    try:
        for sector in blocks:
            offset = sector * EXPECTED_BLOCK_SIZE
            os.lseek(fd, offset, os.SEEK_SET)
            chunk = after[offset:offset + EXPECTED_BLOCK_SIZE]
            written = os.write(fd, chunk)
            if written != len(chunk):
                sys.exit(f"ERROR: short write at sector {sector}.")
        os.fsync(fd)
    finally:
        os.close(fd)

    return blocks


def verify(rawdev: str, expected_img: Path, verify_img: Path):
    print("Reading device back for verification...")
    run([
        "dd",
        f"if={rawdev}",
        f"of={verify_img}",
        f"bs={EXPECTED_BLOCK_SIZE}",
    ], capture=False)

    expected = expected_img.read_bytes()
    actual = verify_img.read_bytes()

    if expected != actual:
        # Report first mismatching sector.
        for sector in range(EXPECTED_SIZE // EXPECTED_BLOCK_SIZE):
            a = sector * EXPECTED_BLOCK_SIZE
            b = a + EXPECTED_BLOCK_SIZE
            if expected[a:b] != actual[a:b]:
                sys.exit(
                    f"ERROR: verification failed at 2048-byte sector {sector}. "
                    "Do NOT press RUN/STOP."
                )
        sys.exit("ERROR: verification failed. Do NOT press RUN/STOP.")

    print("Verification successful: device matches prepared image byte-for-byte.")


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("firmware", type=Path, help="IPS3608 .bin firmware file")
    args = parser.parse_args()

    if sys.platform != "darwin":
        sys.exit("ERROR: this script is intended for macOS.")

    require_root()
    require_mtools()

    firmware = args.firmware.expanduser().resolve()
    if not firmware.is_file():
        sys.exit(f"ERROR: firmware not found: {firmware}")
    if firmware.suffix.lower() != ".bin":
        sys.exit("ERROR: firmware must be a .bin file.")
    if not firmware.name.startswith("APP_IPS3608_"):
        sys.exit(
            "ERROR: unexpected firmware filename. "
            "Expected something beginning with APP_IPS3608_."
        )

    print(f"Firmware: {firmware.name}")
    print(f"Size:     {firmware.stat().st_size} bytes")
    print()

    disk, info = find_ips3608()
    validate_device(disk, info)
    confirm(disk)

    print(f"Unmounting /dev/{disk}...")
    run(["diskutil", "unmountDisk", "force", f"/dev/{disk}"], capture=False)

    rawdev = f"/dev/r{disk}"

    with tempfile.TemporaryDirectory(prefix="ips3608-flash-") as td:
        td = Path(td)
        before_img = td / "before.img"
        after_img = td / "after.img"
        verify_img = td / "verify.img"

        dd_read(rawdev, before_img)
        shutil.copyfile(before_img, after_img)
        add_firmware(after_img, firmware)

        write_changed_blocks(rawdev, before_img, after_img)
        verify(rawdev, after_img, verify_img)

    print()
    print("Firmware file has been written successfully.")
    print("Now SHORT-PRESS RUN/STOP on the IPS3608 to start the firmware update.")
    print("Do not disconnect power or USB while the update is running.")


if __name__ == "__main__":
    main()

Running the script

Save the script as:

ips3608_flash_macos.py

Install mtools:

brew install mtools

Enter Firmware Upgrade mode on the IPS3608 and connect it to the Mac.

Then run:

sudo python3 ips3608_flash_macos.py \
  "$HOME/Downloads/APP_IPS3608_V1.7_260327.bin"

The script should detect:

Found IPS3608: /dev/disk6
  Media:      Msc iap
  Volume:     IPS3608
  Size:       8386560 bytes
  Block size: 2048 bytes
  Protocol:   USB

Before writing anything it will ask:

Type IPS3608 to continue:

Enter:

IPS3608

At the end you should see:

Verification successful:
device matches prepared image byte-for-byte.

Firmware file has been written successfully.
Now SHORT-PRESS RUN/STOP on the IPS3608 to start the firmware update.

Now short-press RUN/STOP on the IPS3608.

The actual firmware programming process should begin.

Do not disconnect USB or power during the update.

Tested configuration

This procedure was tested with:

Device:       FNIRSI IPS3608
Firmware:     APP_IPS3608_V1.7_260327.bin
Host:         Apple Silicon MacBook Air
IAP device:   Msc iap
IAP volume:   IPS3608
Filesystem:   FAT12
Disk size:    8,386,560 bytes
Block size:   2048 bytes

Firmware V1.7 includes:

  • optimized charging logic;
  • additional calibration points for low currents below 0.3 A.

Conclusion

Windows is not actually required to update the FNIRSI IPS3608.

The problem on macOS is the unusual USB mass-storage implementation used by the IPS3608 IAP bootloader.

The device exposes a tiny FAT12 filesystem with a 2048-byte physical block size. macOS can mount it, but a normal file copy may not produce the exact filesystem changes expected by the bootloader.

The reliable workaround is to:

IPS3608
raw IAP image
mtools
modified FAT12 image
compare 2048-byte sectors
write changed sectors only
byte-for-byte verification
RUN/STOP
firmware update

After using this method, the IPS3608 accepted the firmware immediately.