Skip to content

Instantly share code, notes, and snippets.

Forked from stecman/
Last active Dec 4, 2021
What would you like to do?
Brother P-Touch PT-P300BT bluetooth driver python

Controlling the Brother P-Touch Cube label maker from a computer

The Brother PTP300BT label maker is intended to be controlled using the official Brother P-Touch Design & Print iOS/Android app. The app has arbitrary limits on what you can print (1 text object and up to 3 preset icons), so I thought it would be a fun challenge to reverse engineer the protocol to print whatever I wanted.

Python code at the bottom if you want to skip the fine details.

This is a fork of the original gist that adds Python 3 support (3.6+) and have various adjustments. If you want information on the protocol details please refer to the original version

Python code

The code here is what I had at the point I got this working - it's a bit hacked together. It prints images, but the status messages printed aren't complete and the main script needs some tidying up. The printer sometimes goes to an error state after printing (haven't figured out why yet), which can be cleared by pressing the power button once.

This needs a few modules installed to run:


Then it can be used as:

# Existing image (typical use case)
./ <bdaddr of your printer> -i horizontal-label-image.png

# Existing image formated to spec above (advanced)
# -r disables all built-in image pre-processing
./ <bdaddr of your printer> -i monochrome-128px-wide-image.png -r

# (If using option 2)
# Using imagemagick to get a usable input image from any horizontal oriented image
# -resize 128x can be used instead of -crop 128x as needed
# -rotate 90 can be removed if the image is portrait already
convert inputimage.png -monochrome -gravity center -crop 128x -rotate 90 -flop out.png

I was working on Linux, so the serial device is currently hard-coded as /dev/rfcomm0. On OSX, a /dev/tty.* device will show up once the printer is paired.

Unlike the original version, this fork uses pybluez. So the printer needs to be referenced using its BDADDR. It can be found during/after pairing.

To pair the printer with my Linux machine, I used:

# Pair device
$ bluetoothctl
> scan on
... (turn printer on and wait for it to show up: PT-P300BT****)
> pair [address]
#!/usr/bin/env python
from labelmaker_encode import encode_raster_transfer, read_png
import argparse
import bluetooth
import sys
import contextlib
import ctypes
import ptcbp
import ptstatus
BARS = '_▁▂▃▄▅▆▇█'
def parse_args():
p = argparse.ArgumentParser()
p.add_argument('bdaddr', help='BDADDR of the printer.')
p.add_argument('-i', '--image', help='Image file to print.')
p.add_argument('-c', '--rfcomm-channel', help='RFCOMM channel. Normally this does not need to be changed.', default=1, type=int)
p.add_argument('-n', '--no-print', help='Only configure the printer and send the image but do not send print command.', action='store_true')
p.add_argument('-F', '--no-feed', help='Disable feeding at the end of the print (chaining).')
p.add_argument('-a', '--auto-cut', help='Enable auto-cutting (or print label boundary on e.g. PT-P300BT).')
p.add_argument('-m', '--end-margin', help='End margin (in dots).', default=0, type=int)
p.add_argument('-r', '--raw', help='Send the image to printer as-is without any pre-processing.', action='store_true')
p.add_argument('-C', '--nocomp', help='Disable compression.', action='store_true')
return p, p.parse_args()
def reset_printer(ser):
# Flush print buffer
ser.send(b"\x00" * 64)
# Initialize
# Enter raster graphics (PTCBP) mode
ser.send(ptcbp.serialize_control('use_command_set', ptcbp.CommandSet.ptcbp))
def configure_printer(ser, raster_lines, tape_dim, compress=True, chaining=False, auto_cut=False, end_margin=0):
type_, width, length = tape_dim
# Set media & quality
ser.send(ptcbp.serialize_control_obj('set_print_parameters', ptcbp.PrintParameters(
active_fields=(ptcbp.PrintParameterField.width |
ptcbp.PrintParameterField.quality |
width_mm=width, # Tape width in mm
length_mm=length, # Label height in mm (0 for continuous roll)
length_px=raster_lines, # Number of raster lines in image data
is_follow_up=0, # Unused
sbz=0, # Unused
pm, pm2 = 0, 0
if not chaining:
pm2 |= ptcbp.PageModeAdvanced.no_page_chaining
if auto_cut:
pm |= ptcbp.PageMode.auto_cut
# Set print chaining off (0x8) or on (0x0)
ser.send(ptcbp.serialize_control('set_page_mode_advanced', pm2))
# Set no mirror, no auto tape cut
ser.send(ptcbp.serialize_control('set_page_mode', pm))
# Set margin amount (feed amount)
ser.send(ptcbp.serialize_control('set_page_margin', end_margin))
# Set compression mode: TIFF
ser.send(ptcbp.serialize_control('compression', ptcbp.CompressionType.rle if compress else ptcbp.CompressionType.none))
def do_print_job(ser, args, data):
print('=> Querying printer status...')
# Dump status
status = ptstatus.unpack_status(ser.recv(32))
if status.err != 0x0000 or status.phase_type != 0x00 or status.phase != 0x0000:
print('** Printer indicates that it is not ready. Refusing to continue.')
print('=> Configuring printer...')
raster_lines = len(data) // 16
configure_printer(ser, raster_lines, (status.tape_type,
compress=not args.nocomp)
# Send image data
print(f"=> Sending image data ({raster_lines} lines)...")
for line in encode_raster_transfer(data, args.nocomp):
if line[0:1] == b'G':
sys.stdout.write(BARS[min((len(line) - 3) // 2, 7) + 1])
elif line[0:1] == b'Z':
print("=> Image data was sent successfully. Printing will begin soon.")
if not args.no_print:
# Print and feed
# Dump status that the printer returns
status = ptstatus.unpack_status(ser.recv(32))
print("=> All done.")
def main():
p, args = parse_args()
data = None
if args.image is None:
p.error('An image must be specified for printing job.')
# Read input image into memory
if args.raw:
data = read_png(args.image, False, False, False)
data = read_png(args.image)
# Get bluetooth socket
with contextlib.closing(bluetooth.BluetoothSocket(bluetooth.RFCOMM)) as ser:
print('=> Connecting to printer...')
ser.connect((args.bdaddr, args.rfcomm_channel))
assert data is not None
do_print_job(ser, args, data)
# Initialize
if __name__ == '__main__':
import ptcbp
from PIL import Image, ImageOps
from io import BytesIO
def encode_raster_transfer(data, nocomp=False):
""" Encode 1 bit per pixel image data for transfer over serial to the printer """
# Send in chunks of 1 line (128px @ 1bpp = 16 bytes)
# This mirrors the official app from Brother. Other values haven't been tested.
chunk_size = 16
zero_line = bytearray(b'\x00' * chunk_size)
for i in range(0, len(data), chunk_size):
chunk = data[i : i + chunk_size]
if chunk == zero_line:
yield ptcbp.serialize_control('zerofill')
yield ptcbp.serialize_data(chunk, 'none' if nocomp else 'rle')
def read_png(path, transform=True, padding=True, dither=True):
""" Read a image and convert to 1bpp raw data
This should work with any 8 bit PNG. To ensure compatibility, the image can
be processed with Imagemagick first using the -monochrome flag.
image =
tmp = image.convert('1', dither=Image.FLOYDSTEINBERG if dither else Image.NONE)
tmp = ImageOps.invert(tmp.convert('L')).convert('1')
if transform:
tmp = tmp.rotate(-90, expand=True)
tmp = ImageOps.mirror(tmp)
if padding:
w, h = tmp.size
padded ='1', (128, h))
x, y = (128-w)//2, 0
nw, nh = x+w, y+h
padded.paste(tmp, (x, y, nw, nh))
tmp = padded
return tmp.tobytes()
url = ""
verify_ssl = true
name = "pypi"
pybluez = "*"
pillow = "*"
packbits = "*"
python_version = "3.9"
"_meta": {
"hash": {
"sha256": "57b4596eb05edc73b6aea37d91f6a6706620c3d4c9889dc031e2b65fb3d62436"
"pipfile-spec": 6,
"requires": {
"python_version": "3.9"
"sources": [
"name": "pypi",
"url": "",
"verify_ssl": true
"default": {
"packbits": {
"hashes": [
"index": "pypi",
"version": "==0.6"
"pillow": {
"hashes": [
"index": "pypi",
"version": "==8.0.1"
"pybluez": {
"hashes": [
"index": "pypi",
"version": "==0.23"
"develop": {}
#!/usr/bin/env python3
# Simple PTCBP parser
import io
import struct
import enum
from collections import namedtuple
from typing import BinaryIO, Optional, Union
import packbits
# cmd, mnemonic, param_schema, (get_len_from_param, set_len_to_param, min_param_len)
(b'\x00', 'nop', None, None),
(b'\x1b@', 'reset', None, None),
(b'\x1biS', 'get_status', None, None),
(b'\x1bia', 'use_command_set', 'B', None),
(b'\x1biz', 'set_print_parameters', '4BI2B', None),
(b'\x1biM', 'set_page_mode', 'B', None),
(b'\x1biK', 'set_page_mode_advanced', 'B', None),
(b'\x1bid', 'set_page_margin', 'H', None),
(b'M', 'compression', 'B', None),
(b'\x0c', 'print_page', None, None),
(b'\x1a', 'print', None, None),
(b'g', 'data2', 'H', (lambda params: params[0], lambda params, l: params.__setitem__(0, l), 1)),
# TODO is it really for RLE data or just an alias to 'g'?
(b'G', 'data', 'H', (lambda params: params[0], lambda params, l: params.__setitem__(0, l), 1)),
(b'Z', 'zerofill', None, None),
MNEMONICS = {e[1][:]: e for e in CMD_SCHEMA}
OPS_FLAT = {e[0][:]: e for e in CMD_SCHEMA}
def _build_op_tree() -> dict:
tree = {}
for e in CMD_SCHEMA:
current_level = tree
for byte in e[0][:-1]:
if current_level.get(byte) is None:
current_level[byte] = {}
current_level = current_level[byte]
current_level[e[0][-1]] = e
return tree
OPS = _build_op_tree()
PrintParameters = namedtuple('PrintParameters', ('active_fields', 'media_type', 'width_mm', 'length_mm', 'length_px', 'is_follow_up', 'sbz'))
class CompressionType(enum.IntEnum):
none = 0
rle = 2
class CommandSet(enum.IntEnum):
escp = 0
ptcbp = 1
ptouch_template = 3
class PageMode(enum.IntFlag):
auto_cut = 1 << 6
mirror = 1 << 7
class PageModeAdvanced(enum.IntFlag):
half_cut = 1 << 2
no_page_chaining = 1 << 3
no_cutting_on_special_tape = 1 << 4
cut_on_last_label = 1 << 5
high_resolution = 1 << 6
preserve_buffer = 1 << 7
class MediaType(enum.IntEnum):
unloaded = 0x00
laminated = 0x01
non_laminated = 0x03
heat_shrink_tube = 0x11
continuous_tape = 0x4a
die_cut_labels = 0x4b
unknown = 0xff
class PrintParameterField(enum.IntFlag):
media_type = 1 << 1
width = 1 << 2
length = 1 << 3
quality = 1 << 6
recovery = 1 << 7
# TODO other enums
('none', lambda b: b, lambda b: b),
('rle', packbits.encode, packbits.decode),
COMPRESSIONS_TABLE = {c[0]: c[1:] for c in COMPRESSIONS if c is not None}
class Data(object):
def __init__(self, data: bytes, compress: str='none', decompress: str='none') -> None:
for c in (compress, decompress):
raise ValueError(f'Unknown compression type {c}')
self.compress = compress = COMPRESSIONS_TABLE[decompress][1](data)
def getvalue(self) -> bytes:
return COMPRESSIONS_TABLE[self.compress][0](
def getvalue_raw(self) -> bytes:
class Opcode(object):
def __init__(self, op: Optional[bytearray] = None,
op_mnemonic: Optional[str] = None,
params: Optional[Union[list, tuple, bytearray]]=None,
data: Optional[Data]=None,
paramschema: Optional[str]=None,) -> None:
if op is None and op_mnemonic is None:
raise ValueError('op and op_mnemonic cannot both be None')
if op is None:
self.op_mnemonic = op_mnemonic
self.op = op
if paramschema is None:
op_bytes = bytes(self.op)
self.paramschema = struct.Struct(f'<{OPS_FLAT[op_bytes][2]}') if op_bytes in OPS_FLAT and OPS_FLAT[op_bytes][2] is not None else None
self.paramschema = struct.Struct(f'<{paramschema}') if paramschema is not None else None
self.params = params = data
def op_mnemonic(self):
return OPS_FLAT[bytes(self.op)][1] if bytes(self.op) in OPS_FLAT else None
def op_mnemonic(self, val):
op = MNEMONICS[val][0] if val in MNEMONICS else None
if op is None:
raise ValueError(f'Unknown mnemonic {val}')
self.op = op
def serialize(self, to: BinaryIO) -> None:
op_bytes = bytes(self.op)
params = self.params
d = None
if is not None:
if self.paramschema is None or op_bytes not in OPS_FLAT:
raise ValueError('Data attaching not supported')
d =
# convert to list to allow writing and update length
if params is None:
params = []
params = list(params)
if len(params) < OPS_FLAT[op_bytes][3][2]:
params.extend(None for _ in range(OPS_FLAT[op_bytes][3][2]))
OPS_FLAT[op_bytes][3][1](params, len(d))
if self.paramschema is not None:
if params is not None:
# Raw arguments, useful for raw data
elif params is not None:
if d is not None:
def serialize_as_bytes(self) -> bytes:
buf = io.BytesIO()
return buf.getvalue()
def deserialize(cls, ptcbp_stream: BinaryIO, data_compress: str='none') -> object:
op = bytearray()
current_level = OPS
while True:
byte =
if len(byte) == 0:
if len(op) == 0:
return None
raise IOError('Unexpected end of stream')
byte = byte[0]
if byte not in current_level:
raise ValueError(f'Unknown byte 0x{byte:02x} at position {ptcbp_stream.tell():d}')
current_level = current_level[byte]
if not isinstance(current_level, dict):
if current_level[2] is not None:
schema = struct.Struct(current_level[2])
params_raw =
if len(params_raw) != schema.size:
raise IOError('Unexpected end of stream')
params = schema.unpack(params_raw)
params = None
if current_level[3] is not None:
data_len = current_level[3][0](params)
data_raw =
if len(data_raw) != data_len:
raise IOError('Unexpected end of stream')
data = Data(data_raw, compress=data_compress, decompress=data_compress)
data = None
return cls(op=op, params=params, data=data)
def deserialize_from_bytes(cls, ptcbp_bytes: bytes, data_compress: str='none') -> object:
buf = io.BytesIO(ptcbp_bytes)
return cls.deserialize(buf, data_compress)
# Simplified API
def serialize_control(mnemonic: str, *params) -> bytes:
return Opcode(op_mnemonic=mnemonic, params=params or None).serialize_as_bytes()
def serialize_control_obj(mnemonic, params=None):
return Opcode(op_mnemonic=mnemonic, params=params).serialize_as_bytes()
def serialize_data(data, compress='none', use_data2=False):
if use_data2 and compress == 'none':
# Some printers seem to use data2 to transfer uncompressed raster lines.
mnemonic = 'data2'
mnemonic = 'data'
return Opcode(op_mnemonic=mnemonic, data=Data(data, compress=compress)).serialize_as_bytes()
#!/usr/bin/env python3
import ctypes
import sys
import contextlib
import bluetooth
import ptcbp
0: 'Battery full',
1: 'Battery half',
2: 'Battery low',
3: 'Battery critical',
4: 'AC',
0x38: 'QL-800',
0x39: 'QL-810W',
0x41: 'QL-820NWB',
0x66: 'PT-E550W',
0x68: 'PT-P750W',
0x6f: 'PT-P900W',
0x70: 'PT-P950NW',
0x72: 'PT-P300BT',
0: 'Replace media',
1: 'Expansion buffer full',
2: 'Communication error',
3: 'Communication buffer full',
4: 'Cover opened',
5: 'Overheat/Cancelled on printer side',
6: 'Feed error',
7: 'General system error',
8: 'Media not loaded',
9: 'End of media (Page too long)',
10: 'Cutter jammed',
11: 'Low battery',
12: 'Printer in use',
13: 'Printer not powered',
14: 'Overvoltage',
15: 'Fan error',
0x00: 'Not loaded',
0x01: 'Laminated (TZexxx)',
0x03: 'Non-laminated (TZeNxxx)',
0x11: 'Heat shrink tube (HSexxx)',
0x4a: 'Continuous tape',
0x4b: 'Die-cut labels',
0xff: 'Unsupported',
0x000000: 'Ready',
0x000001: 'Feed',
0x010000: 'Printing',
0x010014: 'Cover open while receiving',
0x00: 'None',
0x01: 'White',
0x02: 'Other',
0x03: 'Clear',
0x04: 'Red',
0x05: 'Blue',
0x06: 'Yellow',
0x07: 'Green',
0x08: 'Black',
0x09: 'Clear (White text)',
0x20: 'Matte white',
0x21: 'Matte clear',
0x22: 'Matte silver',
0x23: 'Satin gold',
0x24: 'Satin silver',
0x30: 'Blue (D)',
0x31: 'Red (D)',
0x40: 'Fluorescent orange',
0x41: 'Fluorescent yellow',
0x50: 'Berry pink (S)',
0x51: 'Light gray (S)',
0x52: 'Lime green (S)',
0x60: 'Yellow (F)',
0x61: 'Pink (F)',
0x62: 'Blue (F)',
0x70: 'White (Heat shrink tube)',
0x90: 'White (Flex ID)',
0x91: 'Yellow (Flex ID)',
0xf0: 'Printing head cleaner',
0xf1: 'Stencil',
0xff: 'Unsupported',
0x00: 'None',
0x01: 'White',
0x02: 'Other',
0x04: 'Red',
0x05: 'Blue',
0x08: 'Black',
0x0a: 'Gold',
0x62: 'Blue (F)',
0xf0: 'Printing head cleaner',
0xf1: 'Stencil',
0xff: 'Unsupported',
6: 'Auto cut',
7: 'Hardware mirroring',
0x00: "Reply to status request",
0x01: "Printing completed",
0x02: "Error occured",
0x03: "IF mode finished",
0x04: "Power off",
0x05: "Notification",
0x06: "Phase change",
0x00: 'N/A',
0x01: 'Cover open',
0x02: 'Cover close',
class StatusRegister(ctypes.BigEndianStructure):
_fields_ = (
('magic', ctypes.c_char * 4),
('model', ctypes.c_uint8),
('country', ctypes.c_uint8),
('_err2', ctypes.c_uint8),
('_power', ctypes.c_uint8),
('err', ctypes.c_uint16),
('tape_width', ctypes.c_uint8),
('tape_type', ctypes.c_uint8),
('colors', ctypes.c_uint8),
('fonts', ctypes.c_uint8),
('_sbz0', ctypes.c_uint8),
('mode', ctypes.c_uint8),
('density', ctypes.c_uint8),
('tape_length', ctypes.c_uint8),
('status_type', ctypes.c_uint8),
('phase_type', ctypes.c_uint8),
('phase', ctypes.c_uint16),
('notification', ctypes.c_uint8),
('expansion_area', ctypes.c_uint8),
('tape_bgcolor', ctypes.c_uint8),
('tape_fgcolor', ctypes.c_uint8),
('hw_settings', ctypes.c_uint32),
('_sbz1', ctypes.c_uint8 * 2),
describe_code = lambda code, table: f'{table.get(code, "Unknown")} (0x{code:02x})'
def describe_flag(flagset, descset):
flags = []
ctr = 0
if flagset == 0:
return 'None'
while flagset != 0:
flag = flagset & 1
if flag:
flags.append(descset.get(ctr, 'bit{}'.format(ctr)))
ctr += 1
flagset >>= 1
return ', '.join(flags)
def print_status(stat, verbose=False):
# 0:4
if bytes(stat.magic) != b'\x80\x20B0':
raise RuntimeError('Invalid magic')
# 4
print(f'Model: {describe_code(stat.model, MODELS)}')
# 5:8
if (verbose):
print(f'Country: 0x{}')
print(f'Extended error: 0x{stat._err2:02x}')
print(f'Power: {describe_code(stat._power, POWER)}')
# 8:12
print(f'Errors: {describe_flag(stat.err, ERR_FLAGS)}')
print(f'Tape width: {stat.tape_width}mm')
print(f'Tape type: {describe_code(stat.tape_type, TAPE_TYPE)}')
# 12:15
if (verbose):
# 15
print(f'Print flags: {describe_flag(stat.mode, PRINT_FLAGS)}')
# 16
if (verbose):
# 17
# This would be uglier if written as an f string, so just leave as-is
print('Fixed label length: {}'.format('{}mm'.format(stat.tape_length) if stat.tape_length != 0 else 'N/A'))
# 18
print(f'Status: {describe_code(stat.status_type, STATUS_TYPE)}')
# 19:22
print(f'Phase: {describe_code(stat.phase_type << 16 | stat.phase, PHASES)}')
# 22
print(f'Notification: {describe_code(stat.notification, NOTIFICATIONS)}')
# 23
if (verbose):
print(f'Expansion size: 0x{stat.expansion_area:02x}')
# 24:26
print(f'Tape background: {describe_code(stat.tape_bgcolor, TAPE_BGCOLORS)}')
print(f'Tape foreground: {describe_code(stat.tape_fgcolor, TAPE_FGCOLORS)}')
# 26
if (verbose):
print(f'Hardware settings: 0x{stat.hw_settings:08x}')
def unpack_status(bytes_):
if len(bytes_) != 32:
raise ValueError('Status must be exactly 32 bytes long.')
status = StatusRegister()
ctypes.memmove(ctypes.addressof(status), bytes_, ctypes.sizeof(status))
return status
if __name__ == '__main__':
if len(sys.argv) < 2:
print(f'Usage: {sys.argv[0]} <addr> [ch]')
addr = sys.argv[1]
if len(sys.argv) < 3:
ch = 1
ch = int(sys.argv[2])
with contextlib.closing(bluetooth.BluetoothSocket(bluetooth.RFCOMM)) as sock:
sock.connect((addr, ch))
resp = StatusRegister()
buf = sock.recv(32)
ctypes.memmove(ctypes.addressof(resp), buf, ctypes.sizeof(resp))
print_status(resp, verbose=True)
Copy link

arthursoares commented Nov 10, 2021

Did you have any luck running this under Mac OS / M1 mac? Cheers

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment