Skip to content

Sending Content

send_image

Send Image

Send an image or animation. Supports .png, .webp, .jpg, .jpeg, .bmp, .tiff, .gif and .heic, .heif if pillow-heif library is installed.

Parameters:

Name Type Description Default
path Union[str, Path]

File path.

required
device_info Optional[DeviceInfo]

Device information (injected automatically by DeviceSession).

None
resize_method Union[str, ResizeMethod]

Resize method - 'crop' (default) or 'fit'. 'crop' will fill the entire target area and crop excess. 'fit' will fit the entire image with black padding.

CROP
save_slot int

If >= 1, will save to that slot.

0
Note

If device_info is available, the image will be automatically resized to match the target device dimensions if necessary.

Source code in src/pypixelcolor/commands/send_image.py
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
def send_image(path: Union[str, Path], resize_method: Union[str, ResizeMethod] = ResizeMethod.CROP, device_info: Optional[DeviceInfo] = None, save_slot: int = 0):
    """
    Send an image or animation.
    Supports `.png`, `.webp`, `.jpg`, `.jpeg`, `.bmp`, `.tiff`, `.gif` and `.heic, .heif` if pillow-heif library is installed.

    Args:
        path: File path.
        device_info: Device information (injected automatically by DeviceSession).
        resize_method: Resize method - 'crop' (default) or 'fit'. 
                  'crop' will fill the entire target area and crop excess.
                  'fit' will fit the entire image with black padding.
        save_slot: If >= 1, will save to that slot.

    Note:
        If device_info is available, the image will be automatically resized
        to match the target device dimensions if necessary.
    """

    # Input normalization
    if isinstance(resize_method, str):
        resize_method = ResizeMethod(resize_method)
    if isinstance(path, str):
        path = Path(path)
    save_slot = int(save_slot)

    # Load image data
    if path.exists() and path.is_file():
        with open(path, "rb") as f:
            file_bytes = f.read()
        file_bytes, is_gif = _process_loaded_bytes(file_bytes, path.suffix.lower())
    else:
        raise ValueError(f"File not found: {path}")

    # Resize image if device_info is available
    if device_info is not None:
        file_bytes = _resize_image(file_bytes, is_gif, device_info.width, device_info.height, resize_method)
    else:
        logger.warning("Device info not provided; skipping image resizing.")

    return _build_send_plan(file_bytes, is_gif, plan_name="send_image", save_slot=save_slot)

send_image_hex

Send an image or animation from a hexadecimal string.

Parameters:

Name Type Description Default
hex_string Union[str, bytes]

Hexadecimal representation of image data.

required
file_extension str

File extension to indicate image type (e.g. .png, .gif).

required
device_info Optional[DeviceInfo]

Device information (injected automatically by DeviceSession).

None
resize_method Union[str, ResizeMethod]

Resize method - 'crop' (default) or 'fit'. 'crop' will fill the entire target area and crop excess. 'fit' will fit the entire image with black padding.

CROP
save_slot int

If >= 1, will save to that slot.

0
Source code in src/pypixelcolor/commands/send_image.py
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
def send_image_hex(hex_string: Union[str, bytes], file_extension: str, resize_method: Union[str, ResizeMethod] = ResizeMethod.CROP, device_info: Optional[DeviceInfo] = None, save_slot: int = 0):
    """
    Send an image or animation from a hexadecimal string.

    Args:
        hex_string: Hexadecimal representation of image data.
        file_extension: File extension to indicate image type (e.g. `.png`, `.gif`).
        device_info: Device information (injected automatically by DeviceSession).
        resize_method: Resize method - 'crop' (default) or 'fit'. 
                  'crop' will fill the entire target area and crop excess.
                  'fit' will fit the entire image with black padding.
        save_slot: If >= 1, will save to that slot.
    """
    # Input normalization
    if isinstance(resize_method, str):
        resize_method = ResizeMethod(resize_method)

    # Load image data from hex string
    file_bytes, is_gif = _load_from_hex_string(hex_string, file_extension)

    # Resize image if device_info is available
    if device_info is not None:
        file_bytes = _resize_image(file_bytes, is_gif, device_info.width, device_info.height, resize_method)
    else:
        logger.warning("Device info not provided; skipping image resizing.")

    return _build_send_plan(file_bytes, is_gif, plan_name="send_image_hex", save_slot=save_slot)

send_text

Send Text

Send a text to the device with configurable parameters. If emojis are included in the text, they will be rendered using Twemoji.

Parameters:

Name Type Description Default
text str

The text to send.

required
rainbow_mode int

Rainbow mode (0-9). Defaults to 0.

0
animation int

Animation type (0-7, except 3 and 4). Defaults to 0.

0
save_slot int

Save slot (1-10). Defaults to 1.

0
speed int

Animation speed (0-100). Defaults to 80.

80
color str

Text color in hex. Defaults to "ffffff".

'ffffff'
bg_color str

Background color in hex (e.g., "ff0000" for red). Defaults to None (no background).

None
font str | FontConfig

Built-in font name, file path, or FontConfig object. Defaults to "CUSONG". Built-in fonts are "CUSONG", "SIMSUN", "VCR_OSD_MONO".

'CUSONG'
char_height int

Character height. Auto-detected from device_info if not specified.

None
device_info DeviceInfo

Device information (injected automatically by DeviceSession).

None

Raises:

Type Description
ValueError

If an invalid animation is selected or parameters are out of range.

Source code in src/pypixelcolor/commands/send_text/__init__.py
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
def send_text(text: str,
              rainbow_mode: int = 0,
              animation: int = 0,
              save_slot: int = 0,
              speed: int = 80,
              color: str = "ffffff",
              bg_color: Optional[str] = None,
              font: Union[str, FontConfig] = "CUSONG",
              char_height: Optional[int] = None,
              device_info: Optional[DeviceInfo] = None
              ):
    """
    Send a text to the device with configurable parameters.
    If emojis are included in the text, they will be rendered using Twemoji.

    Args:
        text (str): The text to send.
        rainbow_mode (int, optional): Rainbow mode (0-9). Defaults to 0.
        animation (int, optional): Animation type (0-7, except 3 and 4). Defaults to 0.
        save_slot (int, optional): Save slot (1-10). Defaults to 1.
        speed (int, optional): Animation speed (0-100). Defaults to 80.
        color (str, optional): Text color in hex. Defaults to "ffffff".
        bg_color (str, optional): Background color in hex (e.g., "ff0000" for red). Defaults to None (no background).
        font (str | FontConfig, optional): Built-in font name, file path, or FontConfig object. Defaults to "CUSONG". Built-in fonts are "CUSONG", "SIMSUN", "VCR_OSD_MONO".
        char_height (int, optional): Character height. Auto-detected from device_info if not specified.
        device_info (DeviceInfo, optional): Device information (injected automatically by DeviceSession).

    Raises:
        ValueError: If an invalid animation is selected or parameters are out of range.
    """

    # Resolve font configuration
    font_config = resolve_font_config(font)

    # Auto-detect char_height from device_info if available
    if char_height is None:
        if device_info is not None:
            char_height = get_char_height_from_device(device_info)
            logger.debug(f"Auto-detected matrix height from device (height={device_info.height}): {char_height}")
        else:
            raise ValueError("char_height must be specified if device_info is not provided")

    char_height = int(char_height)

    # Get metrics for this character height
    metrics = font_config.get_metrics(char_height)
    font_size = metrics["font_size"]
    font_offset = metrics["offset"]
    pixel_threshold = metrics["pixel_threshold"]
    var_width = metrics["var_width"]

    # properties: 3 fixed bytes + animation + speed + rainbow + 3 bytes color + 1 byte bg flag + 3 bytes bg color
    try:
        color_bytes = bytes.fromhex(color)
    except Exception:
        raise ValueError(f"Invalid color hex: {color}")
    if len(color_bytes) != 3:
        raise ValueError("Color must be 3 bytes (6 hex chars), e.g. 'ffffff'")

    # Validate parameter ranges
    checks = [
        (int(rainbow_mode), 0, 9, "Rainbow mode"),
        (int(animation), 0, 7, "Animation"),
        (int(save_slot), 0, 255, "Save slot"),
        (int(speed), 0, 100, "Speed"),
        (len(text), 1, 500, "Text length"),
        (char_height, 1, 128, "Char height"),
    ]
    for param, min_val, max_val, name in checks:
        if not (min_val <= param <= max_val):
            raise ValueError(f"{name} must be between {min_val} and {max_val} (got {param})")

    # Disable unsupported animations (bootloop)
    if device_info and (device_info.height != 32 or device_info.width != 32):
        if (int(animation) == 3 or int(animation) == 4):
            raise ValueError("This animation is not supported with this font on non-32x32 devices.")

    # Determine if RTL mode should be enabled (only for animation 2)
    rtl = (int(animation) == 2)
    if rtl:
        logger.debug("Reversed chunk order for RTL display")

    #---------------- BUILD PAYLOAD ----------------#

    #########################
    #       PROPERTIES      #
    #########################

    properties = bytearray()
    properties += bytes([
        0x00,   # Reserved
        0x01,   # Reserved
        0x01    # Reserved
    ])
    properties += bytes([
        int(animation) & 0xFF,      # Animation
        int(speed) & 0xFF,          # Speed
        int(rainbow_mode) & 0xFF    # Rainbow mode
    ])
    properties += color_bytes

    # Trailing 4 bytes - Background color: [enable_flag, R, G, B]
    if bg_color is not None:
        try:
            bg_color_bytes = bytes.fromhex(bg_color)
        except Exception:
            raise ValueError(f"Invalid background color hex: {bg_color}")
        if len(bg_color_bytes) != 3:
            raise ValueError("Background color must be 3 bytes (6 hex chars), e.g. 'ff0000'")
        properties += bytes([0x01])  # Enable background
        properties += bg_color_bytes
        logger.info(f"Background color enabled: #{bg_color}")
    else:
        properties += bytes([
            0x00,   # Background disabled
            0x00,   # R (unused)
            0x00,   # G (unused)
            0x00    # B (unused)
        ])

    #########################
    #       CHARACTERS      #
    #########################

    if var_width:
        # Determine chunk width based on char_height
        chunk_width = 8  if char_height <= 20 else 16

        # Encode text with chunks and emoji support, getting both bytes and item count
        characters_bytes, num_chars = encode_text_chunked(
            text,
            char_height,
            color,
            font_config.path,
            font_offset,
            font_size,
            pixel_threshold,
            chunk_width,
            reverse=rtl
        )
    else:
        # Original character-by-character encoding
        characters_bytes = encode_text(
            text,
            char_height,
            color,
            font_config.path,
            font_offset,
            font_size,
            pixel_threshold,
            reverse=rtl
        )

        # Number of characters is the length of the text
        num_chars = len(text)

    # Build data payload with character count
    data_payload = bytes([num_chars]) + properties + characters_bytes

    #########################
    #        CHECKSUM       #
    #########################

    crc = binascii.crc32(data_payload) & 0xFFFFFFFF
    payload_size = len(data_payload)

    #########################
    #      MULTI-FRAME      #
    #########################

    windows = []
    window_size = 12 * 1024
    pos = 0
    window_index = 0

    while pos < payload_size:
        window_end = min(pos + window_size, payload_size)
        chunk_payload = data_payload[pos:window_end]

        # Option: 0x00 for first frame, 0x02 for subsequent frames
        option = 0x00 if window_index == 0 else 0x02

        # Construct header for this frame
        # [00 01 Option] [Payload Size (4)] [CRC (4)] [00 SaveSlot]

        frame_header = bytearray()
        frame_header += bytes([
            0x00,   # Reserved
            0x01,   # Command
            option  # Option
        ])

        # Payload Size (Total) - 4 bytes little endian
        frame_header += payload_size.to_bytes(4, byteorder="little")

        # CRC - 4 bytes little endian
        frame_header += crc.to_bytes(4, byteorder="little")

        # Tail - 2 bytes
        frame_header += bytes([0x00])                   # Reserved
        frame_header += bytes([int(save_slot) & 0xFF])  # save_slot

        # Combine header and chunk
        frame_content = frame_header + chunk_payload

        # Calculate frame length prefix
        # Total size = len(frame_content) + 2 (for the prefix itself)
        frame_len = len(frame_content) + 2
        prefix = frame_len.to_bytes(2, byteorder="little")

        message = prefix + frame_content
        windows.append(Window(data=message, requires_ack=True))

        window_index += 1
        pos = window_end

    logger.info(f"Split text into {len(windows)} frames")
    return SendPlan("send_text", windows)