flipshot 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
flipshot-1.0.0/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+
2
+ The MIT License (MIT)
3
+
4
+ Copyright (c) 2026 Ilia Petrov-Komotskii (Mane Function)
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
@@ -0,0 +1,81 @@
1
+ Metadata-Version: 2.4
2
+ Name: flipshot
3
+ Version: 1.0.0
4
+ Summary: Grab a screenshot from a Flipper Zero over USB serial.
5
+ Author-email: Ilia Petrov-Komotskii <ilia@inkedkettle.art>
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/ManeFunction/flipshot
8
+ Keywords: flipper-zero,flipper,screenshot,serial,cli
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Environment :: Console
12
+ Classifier: Topic :: Utilities
13
+ Requires-Python: >=3.8
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: pyserial>=3.5
17
+ Dynamic: license-file
18
+
19
+ # flipshot
20
+
21
+ Grab one frame from a [Flipper Zero](https://flipperzero.one/)'s screen over USB serial and
22
+ save it as a native-resolution (128x64) black & white PNG — no qFlipper, no companion app,
23
+ just a serial cable and this script.
24
+
25
+
26
+ ## Installation
27
+
28
+ **flipshot** is available from a variety of sources.
29
+ `pip` or `brew` is recommended, because they have a convenient way to manage updates automatically.
30
+
31
+ 1) **pip (Recommended for everyone with a Python environment)**
32
+ - You can check if you have Python installed by running `python --version` in the Terminal or cmd.
33
+ - For Mac and Linux users, there is a high chance that you already have Python installed on your system.
34
+ - For Windows users, you can download Python from the [official website](https://www.python.org/downloads/).
35
+ - After confirmation, install **flipshot** through the [PyPI](https://pypi.org/project/flipshot) package
36
+ manager, typing `pip install flipshot` in the Terminal. For Mac users, you may need to use `pip3` instead
37
+ of `pip`.
38
+ - Verify the installation with `flipshot --version`.
39
+ - You are perfect, you can use the app with `flipshot [port] [output.png]` from any folder in your system.
40
+ 2) **brew (Recommended for Mac and Linux users)**
41
+ - Type `brew tap manefunction/tap` in your Terminal to add my custom tap (app source) to your brew sources,
42
+ if you haven't already.
43
+ - Type `brew install flipshot` to install the application itself.
44
+ - Verify the installation with `flipshot --version`.
45
+ - You are perfect, you can use the app with `flipshot [port] [output.png]` from any folder in your system.
46
+ 3) **Python package (manual installation, for advanced users)**
47
+ - Clone the repository or download the source code from GitHub.
48
+ - Go to the folder with the script in your Terminal.
49
+ - Run `pip install .` to install `flipshot` to your system.
50
+ - Run `flipshot --version` to verify the script is working.
51
+ 4) **Python script (manual usage, for advanced users)**
52
+ - If you are familiar with Python scripts, venv, and dependencies, you can simply clone the repository,
53
+ `pip3 install pyserial`, and run `src/flipshot.py` directly. Feel free to modify the script for yourself.
54
+
55
+
56
+ ## Usage
57
+
58
+ ```
59
+ flipshot [serial_port] [output.png]
60
+ ```
61
+
62
+ - `serial_port` is optional — flipshot auto-detects a connected Flipper Zero over USB.
63
+ Pass it explicitly if auto-detection fails, e.g. `flipshot /dev/cu.usbmodemflip_XXXX1`.
64
+ - `output.png` is optional — defaults to `flipshot-<device-name>-<YYYY-MM-DD--HH-MM-SS-MSS>.png`
65
+ in the current folder.
66
+
67
+ Close qFlipper or any other serial terminal connected to the Flipper before running flipshot —
68
+ only one process can hold the serial port at a time.
69
+
70
+
71
+ ## How it works
72
+
73
+ flipshot switches the Flipper's CLI into its length-prefixed protobuf RPC mode over the same
74
+ USB-serial connection qFlipper uses, requests a screen-stream frame and the device's hardware
75
+ name, and encodes the resulting 1-bit framebuffer as a PNG — all with the Python standard
76
+ library plus [pyserial](https://pypi.org/project/pyserial/); no image library required.
77
+
78
+
79
+ ## Repository info
80
+
81
+ This repo follows the [Conventional Commits](https://www.conventionalcommits.org/) specification.
@@ -0,0 +1,63 @@
1
+ # flipshot
2
+
3
+ Grab one frame from a [Flipper Zero](https://flipperzero.one/)'s screen over USB serial and
4
+ save it as a native-resolution (128x64) black & white PNG — no qFlipper, no companion app,
5
+ just a serial cable and this script.
6
+
7
+
8
+ ## Installation
9
+
10
+ **flipshot** is available from a variety of sources.
11
+ `pip` or `brew` is recommended, because they have a convenient way to manage updates automatically.
12
+
13
+ 1) **pip (Recommended for everyone with a Python environment)**
14
+ - You can check if you have Python installed by running `python --version` in the Terminal or cmd.
15
+ - For Mac and Linux users, there is a high chance that you already have Python installed on your system.
16
+ - For Windows users, you can download Python from the [official website](https://www.python.org/downloads/).
17
+ - After confirmation, install **flipshot** through the [PyPI](https://pypi.org/project/flipshot) package
18
+ manager, typing `pip install flipshot` in the Terminal. For Mac users, you may need to use `pip3` instead
19
+ of `pip`.
20
+ - Verify the installation with `flipshot --version`.
21
+ - You are perfect, you can use the app with `flipshot [port] [output.png]` from any folder in your system.
22
+ 2) **brew (Recommended for Mac and Linux users)**
23
+ - Type `brew tap manefunction/tap` in your Terminal to add my custom tap (app source) to your brew sources,
24
+ if you haven't already.
25
+ - Type `brew install flipshot` to install the application itself.
26
+ - Verify the installation with `flipshot --version`.
27
+ - You are perfect, you can use the app with `flipshot [port] [output.png]` from any folder in your system.
28
+ 3) **Python package (manual installation, for advanced users)**
29
+ - Clone the repository or download the source code from GitHub.
30
+ - Go to the folder with the script in your Terminal.
31
+ - Run `pip install .` to install `flipshot` to your system.
32
+ - Run `flipshot --version` to verify the script is working.
33
+ 4) **Python script (manual usage, for advanced users)**
34
+ - If you are familiar with Python scripts, venv, and dependencies, you can simply clone the repository,
35
+ `pip3 install pyserial`, and run `src/flipshot.py` directly. Feel free to modify the script for yourself.
36
+
37
+
38
+ ## Usage
39
+
40
+ ```
41
+ flipshot [serial_port] [output.png]
42
+ ```
43
+
44
+ - `serial_port` is optional — flipshot auto-detects a connected Flipper Zero over USB.
45
+ Pass it explicitly if auto-detection fails, e.g. `flipshot /dev/cu.usbmodemflip_XXXX1`.
46
+ - `output.png` is optional — defaults to `flipshot-<device-name>-<YYYY-MM-DD--HH-MM-SS-MSS>.png`
47
+ in the current folder.
48
+
49
+ Close qFlipper or any other serial terminal connected to the Flipper before running flipshot —
50
+ only one process can hold the serial port at a time.
51
+
52
+
53
+ ## How it works
54
+
55
+ flipshot switches the Flipper's CLI into its length-prefixed protobuf RPC mode over the same
56
+ USB-serial connection qFlipper uses, requests a screen-stream frame and the device's hardware
57
+ name, and encodes the resulting 1-bit framebuffer as a PNG — all with the Python standard
58
+ library plus [pyserial](https://pypi.org/project/pyserial/); no image library required.
59
+
60
+
61
+ ## Repository info
62
+
63
+ This repo follows the [Conventional Commits](https://www.conventionalcommits.org/) specification.
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=42", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "flipshot"
7
+ version = "1.0.0"
8
+ description = "Grab a screenshot from a Flipper Zero over USB serial."
9
+ authors = [
10
+ { name = "Ilia Petrov-Komotskii", email = "ilia@inkedkettle.art" }
11
+ ]
12
+ license = "MIT"
13
+ readme = "README.md"
14
+ requires-python = ">=3.8"
15
+ keywords = ["flipper-zero", "flipper", "screenshot", "serial", "cli"]
16
+ classifiers = [
17
+ "Programming Language :: Python :: 3",
18
+ "Operating System :: OS Independent",
19
+ "Environment :: Console",
20
+ "Topic :: Utilities",
21
+ ]
22
+ dependencies = [
23
+ "pyserial>=3.5",
24
+ ]
25
+
26
+ [project.urls]
27
+ Repository = "https://github.com/ManeFunction/flipshot"
28
+
29
+ [project.scripts]
30
+ flipshot = "flipshot:main"
31
+
32
+ [tool.setuptools]
33
+ py-modules = ["flipshot"]
34
+
35
+ [tool.setuptools.package-dir]
36
+ "" = "src"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,81 @@
1
+ Metadata-Version: 2.4
2
+ Name: flipshot
3
+ Version: 1.0.0
4
+ Summary: Grab a screenshot from a Flipper Zero over USB serial.
5
+ Author-email: Ilia Petrov-Komotskii <ilia@inkedkettle.art>
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/ManeFunction/flipshot
8
+ Keywords: flipper-zero,flipper,screenshot,serial,cli
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Environment :: Console
12
+ Classifier: Topic :: Utilities
13
+ Requires-Python: >=3.8
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: pyserial>=3.5
17
+ Dynamic: license-file
18
+
19
+ # flipshot
20
+
21
+ Grab one frame from a [Flipper Zero](https://flipperzero.one/)'s screen over USB serial and
22
+ save it as a native-resolution (128x64) black & white PNG — no qFlipper, no companion app,
23
+ just a serial cable and this script.
24
+
25
+
26
+ ## Installation
27
+
28
+ **flipshot** is available from a variety of sources.
29
+ `pip` or `brew` is recommended, because they have a convenient way to manage updates automatically.
30
+
31
+ 1) **pip (Recommended for everyone with a Python environment)**
32
+ - You can check if you have Python installed by running `python --version` in the Terminal or cmd.
33
+ - For Mac and Linux users, there is a high chance that you already have Python installed on your system.
34
+ - For Windows users, you can download Python from the [official website](https://www.python.org/downloads/).
35
+ - After confirmation, install **flipshot** through the [PyPI](https://pypi.org/project/flipshot) package
36
+ manager, typing `pip install flipshot` in the Terminal. For Mac users, you may need to use `pip3` instead
37
+ of `pip`.
38
+ - Verify the installation with `flipshot --version`.
39
+ - You are perfect, you can use the app with `flipshot [port] [output.png]` from any folder in your system.
40
+ 2) **brew (Recommended for Mac and Linux users)**
41
+ - Type `brew tap manefunction/tap` in your Terminal to add my custom tap (app source) to your brew sources,
42
+ if you haven't already.
43
+ - Type `brew install flipshot` to install the application itself.
44
+ - Verify the installation with `flipshot --version`.
45
+ - You are perfect, you can use the app with `flipshot [port] [output.png]` from any folder in your system.
46
+ 3) **Python package (manual installation, for advanced users)**
47
+ - Clone the repository or download the source code from GitHub.
48
+ - Go to the folder with the script in your Terminal.
49
+ - Run `pip install .` to install `flipshot` to your system.
50
+ - Run `flipshot --version` to verify the script is working.
51
+ 4) **Python script (manual usage, for advanced users)**
52
+ - If you are familiar with Python scripts, venv, and dependencies, you can simply clone the repository,
53
+ `pip3 install pyserial`, and run `src/flipshot.py` directly. Feel free to modify the script for yourself.
54
+
55
+
56
+ ## Usage
57
+
58
+ ```
59
+ flipshot [serial_port] [output.png]
60
+ ```
61
+
62
+ - `serial_port` is optional — flipshot auto-detects a connected Flipper Zero over USB.
63
+ Pass it explicitly if auto-detection fails, e.g. `flipshot /dev/cu.usbmodemflip_XXXX1`.
64
+ - `output.png` is optional — defaults to `flipshot-<device-name>-<YYYY-MM-DD--HH-MM-SS-MSS>.png`
65
+ in the current folder.
66
+
67
+ Close qFlipper or any other serial terminal connected to the Flipper before running flipshot —
68
+ only one process can hold the serial port at a time.
69
+
70
+
71
+ ## How it works
72
+
73
+ flipshot switches the Flipper's CLI into its length-prefixed protobuf RPC mode over the same
74
+ USB-serial connection qFlipper uses, requests a screen-stream frame and the device's hardware
75
+ name, and encodes the resulting 1-bit framebuffer as a PNG — all with the Python standard
76
+ library plus [pyserial](https://pypi.org/project/pyserial/); no image library required.
77
+
78
+
79
+ ## Repository info
80
+
81
+ This repo follows the [Conventional Commits](https://www.conventionalcommits.org/) specification.
@@ -0,0 +1,10 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/flipshot.py
5
+ src/flipshot.egg-info/PKG-INFO
6
+ src/flipshot.egg-info/SOURCES.txt
7
+ src/flipshot.egg-info/dependency_links.txt
8
+ src/flipshot.egg-info/entry_points.txt
9
+ src/flipshot.egg-info/requires.txt
10
+ src/flipshot.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ flipshot = flipshot:main
@@ -0,0 +1 @@
1
+ pyserial>=3.5
@@ -0,0 +1 @@
1
+ flipshot
@@ -0,0 +1,460 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Grab one frame from a Flipper Zero's screen via its RPC protocol and save it
4
+ as a native-resolution (128x64) black & white PNG.
5
+
6
+ Install with:
7
+ pip3 install flipshot
8
+ or from source:
9
+ pip3 install pyserial
10
+
11
+ Use pip3 or python3 -m pip, not bare pip (on macOS that often targets Apple Python 3.9).
12
+ Homebrew Python may require break-system-packages in ~/.config/pip/pip.conf or on the command line.
13
+ If import serial fails after installing pyserial, run: pip3 uninstall serial
14
+
15
+ Usage:
16
+ flipshot [serial_port] [output.png]
17
+
18
+ If serial_port is omitted, the script tries to auto-detect a connected Flipper.
19
+ If output.png is omitted, the file name is
20
+ flipshot-<device-name>-<YYYY-MM-DD--HH-MM-SS-MSS>.png
21
+ """
22
+
23
+ import argparse
24
+ import re
25
+ import struct
26
+ import sys
27
+ import time
28
+ import zlib
29
+ from datetime import datetime
30
+ from importlib.metadata import PackageNotFoundError
31
+ from importlib.metadata import version as _pkg_version
32
+ from typing import Optional, Tuple
33
+
34
+ import serial
35
+ import serial.tools.list_ports
36
+
37
+ SCREEN_W, SCREEN_H = 128, 64
38
+
39
+ # --- Flipper RPC field numbers (from flipperdevices/flipperzero-protobuf) ---
40
+ # Main message: command_id=1 (varint), command_status=2 (varint), has_next=3 (varint)
41
+ FIELD_COMMAND_ID = 1
42
+ FIELD_HAS_NEXT = 3
43
+ FIELD_GUI_START_SCREEN_STREAM = 20 # oneof: StartScreenStreamRequest (empty)
44
+ FIELD_GUI_STOP_SCREEN_STREAM = 21 # oneof: StopScreenStreamRequest (empty)
45
+ FIELD_GUI_SCREEN_FRAME = 22 # oneof: ScreenFrame { bytes data = 1; ... }
46
+ FIELD_SYSTEM_DEVICE_INFO_REQUEST = 32
47
+ FIELD_SYSTEM_DEVICE_INFO_RESPONSE = 33
48
+ FIELD_SCREEN_FRAME_DATA = 1
49
+ FIELD_DEVICE_INFO_KEY = 1
50
+ FIELD_DEVICE_INFO_VALUE = 2
51
+
52
+ DEVICE_NAME_INFO_KEYS = ("hardware.name", "hardware_name")
53
+
54
+ FLIPPER_USB_VID = 0x0483
55
+ FLIPPER_USB_PID = 0x5740
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Minimal protobuf varint + wire-format helpers (no protobuf library needed)
60
+ # ---------------------------------------------------------------------------
61
+ def encode_varint(value: int) -> bytes:
62
+ out = bytearray()
63
+ while True:
64
+ b = value & 0x7F
65
+ value >>= 7
66
+ if value:
67
+ out.append(b | 0x80)
68
+ else:
69
+ out.append(b)
70
+ return bytes(out)
71
+
72
+
73
+ def encode_tag(field_number: int, wire_type: int) -> bytes:
74
+ return encode_varint((field_number << 3) | wire_type)
75
+
76
+
77
+ def encode_length_delimited(field_number: int, payload: bytes) -> bytes:
78
+ return encode_tag(field_number, 2) + encode_varint(len(payload)) + payload
79
+
80
+
81
+ def encode_varint_field(field_number: int, value: int) -> bytes:
82
+ return encode_tag(field_number, 0) + encode_varint(value)
83
+
84
+
85
+ def read_varint(read_byte):
86
+ """read_byte() must return the next single byte (int) from the stream."""
87
+ result = 0
88
+ shift = 0
89
+ while True:
90
+ b = read_byte()
91
+ result |= (b & 0x7F) << shift
92
+ if not (b & 0x80):
93
+ return result
94
+ shift += 7
95
+
96
+
97
+ def iter_fields(buf: bytes):
98
+ """Yield (field_number, wire_type, value) for a flat protobuf message.
99
+ value is an int for wire type 0/1/5, or raw bytes for wire type 2."""
100
+ i = 0
101
+ n = len(buf)
102
+ while i < n:
103
+ tag, shift = 0, 0
104
+ while True:
105
+ b = buf[i]
106
+ i += 1
107
+ tag |= (b & 0x7F) << shift
108
+ if not (b & 0x80):
109
+ break
110
+ shift += 7
111
+ field_number = tag >> 3
112
+ wire_type = tag & 0x7
113
+
114
+ if wire_type == 0: # varint
115
+ value, shift = 0, 0
116
+ while True:
117
+ b = buf[i]
118
+ i += 1
119
+ value |= (b & 0x7F) << shift
120
+ if not (b & 0x80):
121
+ break
122
+ shift += 7
123
+ yield field_number, wire_type, value
124
+ elif wire_type == 1: # 64-bit
125
+ yield field_number, wire_type, buf[i:i + 8]
126
+ i += 8
127
+ elif wire_type == 2: # length-delimited
128
+ length, shift = 0, 0
129
+ while True:
130
+ b = buf[i]
131
+ i += 1
132
+ length |= (b & 0x7F) << shift
133
+ if not (b & 0x80):
134
+ break
135
+ shift += 7
136
+ yield field_number, wire_type, buf[i:i + length]
137
+ i += length
138
+ elif wire_type == 5: # 32-bit
139
+ yield field_number, wire_type, buf[i:i + 4]
140
+ i += 4
141
+ else:
142
+ raise ValueError(f"Unsupported wire type {wire_type}")
143
+
144
+
145
+ def find_field(buf: bytes, wanted_field_number: int):
146
+ for field_number, _wire_type, value in iter_fields(buf):
147
+ if field_number == wanted_field_number:
148
+ return value
149
+ return None
150
+
151
+
152
+ # ---------------------------------------------------------------------------
153
+ # Serial / RPC session handling
154
+ # ---------------------------------------------------------------------------
155
+ def sanitize_filename_component(value: str) -> str:
156
+ cleaned = re.sub(r"[^\w\-.]+", "_", value.strip(), flags=re.ASCII)
157
+ return cleaned or "unknown"
158
+
159
+
160
+ def default_output_path(device_name: str) -> str:
161
+ now = datetime.now()
162
+ timestamp = (
163
+ now.strftime("%Y-%m-%d--%H-%M-%S")
164
+ + f"-{now.microsecond // 1000:03d}"
165
+ )
166
+ safe_name = sanitize_filename_component(device_name)
167
+ return f"flipshot-{safe_name}-{timestamp}.png"
168
+
169
+
170
+ def decode_string_field(buf: bytes, field_number: int) -> Optional[str]:
171
+ raw = find_field(buf, field_number)
172
+ if raw is None:
173
+ return None
174
+ return raw.decode("utf-8", errors="replace")
175
+
176
+
177
+ def read_frame_and_device_name(ser: serial.Serial, timeout: float) -> Tuple[Optional[bytes], str]:
178
+ """Read screen-frame and device-info responses off the same stream, whichever
179
+ arrives first. The two requests are independent, so there's no need to wait
180
+ for one to fully finish before sending (and waiting on) the other."""
181
+ deadline = time.time() + timeout
182
+ frame_data = None
183
+ device_name = None
184
+ device_info_done = False
185
+ while time.time() < deadline and (frame_data is None or not device_info_done):
186
+ msg = read_message(ser)
187
+
188
+ if frame_data is None:
189
+ screen_frame = find_field(msg, FIELD_GUI_SCREEN_FRAME)
190
+ if screen_frame is not None:
191
+ data = find_field(screen_frame, FIELD_SCREEN_FRAME_DATA)
192
+ if data:
193
+ frame_data = data
194
+ continue
195
+
196
+ info = find_field(msg, FIELD_SYSTEM_DEVICE_INFO_RESPONSE)
197
+ if info is not None:
198
+ key = decode_string_field(info, FIELD_DEVICE_INFO_KEY)
199
+ value = decode_string_field(info, FIELD_DEVICE_INFO_VALUE)
200
+ if key in DEVICE_NAME_INFO_KEYS and value:
201
+ device_name = value
202
+
203
+ has_next = find_field(msg, FIELD_HAS_NEXT)
204
+ if has_next is None or has_next == 0:
205
+ device_info_done = True
206
+
207
+ return frame_data, (device_name or "unknown")
208
+
209
+
210
+ def is_flipper_port(port_info: serial.tools.list_ports.ListPortInfo) -> bool:
211
+ device = (port_info.device or "").lower()
212
+ description = (port_info.description or "").lower()
213
+ manufacturer = (port_info.manufacturer or "").lower()
214
+ if "flip" in device or "usbmodemflip" in device:
215
+ return True
216
+ if "flipper" in description or "flip_" in description:
217
+ return True
218
+ if "flipper" in manufacturer:
219
+ return True
220
+ if port_info.vid == FLIPPER_USB_VID and port_info.pid == FLIPPER_USB_PID:
221
+ return True
222
+ return False
223
+
224
+
225
+ def find_flipper_port() -> Optional[str]:
226
+ matches = [p for p in serial.tools.list_ports.comports() if is_flipper_port(p)]
227
+ if not matches:
228
+ return None
229
+ for port_info in matches:
230
+ if port_info.device.startswith("/dev/cu."):
231
+ return port_info.device
232
+ return matches[0].device
233
+
234
+
235
+ def quit_message(message: str, code: int = 1) -> None:
236
+ print(message)
237
+ sys.exit(code)
238
+
239
+
240
+ def _drain_until_idle(ser: serial.Serial, idle_gap: float = 0.05, max_wait: float = 0.5) -> None:
241
+ """Read and discard bytes until the port has been quiet for idle_gap seconds,
242
+ or max_wait total has elapsed (same worst case as a blind sleep, but returns
243
+ as soon as the Flipper actually stops talking)."""
244
+ original_timeout = ser.timeout
245
+ ser.timeout = idle_gap
246
+ try:
247
+ deadline = time.time() + max_wait
248
+ while time.time() < deadline:
249
+ if not ser.read(4096):
250
+ return
251
+ finally:
252
+ ser.timeout = original_timeout
253
+
254
+
255
+ def start_rpc_session(ser: serial.Serial) -> None:
256
+ """Switch Flipper CLI from text mode to length-prefixed protobuf RPC."""
257
+ ser.rts = True
258
+ time.sleep(0.5) # let the Flipper notice the RTS toggle; nothing to poll on yet
259
+ ser.reset_input_buffer()
260
+ ser.write(b"\r")
261
+ _drain_until_idle(ser, max_wait=0.3)
262
+ # Flipper expects CR only here; CRLF does not enter RPC mode reliably.
263
+ ser.write(b"start_rpc_session\r")
264
+ _drain_until_idle(ser, max_wait=0.5)
265
+ ser.reset_input_buffer()
266
+
267
+
268
+ def write_message(ser: serial.Serial, body: bytes):
269
+ ser.write(encode_varint(len(body)) + body)
270
+ ser.flush()
271
+
272
+
273
+ def read_message(ser: serial.Serial) -> bytes:
274
+ def read_byte():
275
+ b = ser.read(1)
276
+ if not b:
277
+ raise TimeoutError("Serial read timed out waiting for RPC data")
278
+ return b[0]
279
+
280
+ length = read_varint(read_byte)
281
+ data = b""
282
+ while len(data) < length:
283
+ chunk = ser.read(length - len(data))
284
+ if not chunk:
285
+ raise TimeoutError("Serial read timed out mid-message")
286
+ data += chunk
287
+ return data
288
+
289
+
290
+ def grab_screen_frame(port: str, timeout: float = 5.0) -> Tuple[bytes, str]:
291
+ ser = serial.Serial(
292
+ port,
293
+ baudrate=115200,
294
+ timeout=timeout,
295
+ dsrdtr=False,
296
+ write_timeout=5,
297
+ )
298
+ try:
299
+ start_rpc_session(ser)
300
+
301
+ # Fire both requests up front -- they're independent, so we can read
302
+ # whichever responses arrive first instead of waiting on them in series.
303
+ start_stream_request = encode_varint_field(
304
+ FIELD_COMMAND_ID, 1
305
+ ) + encode_length_delimited(FIELD_GUI_START_SCREEN_STREAM, b"")
306
+ write_message(ser, start_stream_request)
307
+
308
+ device_info_request = encode_varint_field(
309
+ FIELD_COMMAND_ID, 10
310
+ ) + encode_length_delimited(FIELD_SYSTEM_DEVICE_INFO_REQUEST, b"")
311
+ write_message(ser, device_info_request)
312
+
313
+ try:
314
+ frame_data, device_name = read_frame_and_device_name(ser, timeout)
315
+ except TimeoutError:
316
+ frame_data, device_name = None, "unknown"
317
+
318
+ # Politely stop the stream regardless of success.
319
+ stop_request = encode_varint_field(FIELD_COMMAND_ID, 2) + encode_length_delimited(
320
+ FIELD_GUI_STOP_SCREEN_STREAM, b""
321
+ )
322
+ write_message(ser, stop_request)
323
+
324
+ if frame_data is None:
325
+ raise RuntimeError("Never received a screen frame before timing out")
326
+ if len(frame_data) != (SCREEN_W * SCREEN_H) // 8:
327
+ raise RuntimeError(
328
+ f"Unexpected frame size {len(frame_data)} bytes "
329
+ f"(expected {(SCREEN_W * SCREEN_H) // 8})"
330
+ )
331
+
332
+ return frame_data, device_name
333
+ finally:
334
+ ser.close()
335
+
336
+
337
+ # ---------------------------------------------------------------------------
338
+ # Framebuffer -> PNG (no imaging library needed -- just stdlib zlib/struct)
339
+ # ---------------------------------------------------------------------------
340
+ def frame_to_pixels(frame_data: bytes) -> bytes:
341
+ """Flipper's framebuffer is SSD1306-style page-addressed: 8 pages of 8 rows,
342
+ 128 columns, each byte = one column's 8 vertical pixels, LSB = topmost row.
343
+ A set bit means "ink" (matches this project's own icon PNG convention:
344
+ 1 = black). Flip the 0x00/0xFF pair below if yours comes out inverted on
345
+ your firmware version. Returns SCREEN_W*SCREEN_H grayscale bytes, row-major."""
346
+ pixels = bytearray(b"\xff" * (SCREEN_W * SCREEN_H)) # 0xff = white background
347
+ for i, byte in enumerate(frame_data):
348
+ page = i // SCREEN_W
349
+ col = i % SCREEN_W
350
+ for bit in range(8):
351
+ y = page * 8 + bit
352
+ if y >= SCREEN_H:
353
+ continue
354
+ on = (byte >> bit) & 1
355
+ pixels[y * SCREEN_W + col] = 0x00 if on else 0xFF
356
+ return bytes(pixels)
357
+
358
+
359
+ def _png_chunk(chunk_type: bytes, data: bytes) -> bytes:
360
+ return (
361
+ struct.pack(">I", len(data))
362
+ + chunk_type
363
+ + data
364
+ + struct.pack(">I", zlib.crc32(chunk_type + data) & 0xFFFFFFFF)
365
+ )
366
+
367
+
368
+ def save_png(path: str, pixels: bytes, width: int, height: int) -> None:
369
+ """Write an 8-bit grayscale PNG using only the standard library."""
370
+ ihdr = struct.pack(">IIBBBBB", width, height, 8, 0, 0, 0, 0)
371
+
372
+ raw = bytearray()
373
+ for y in range(height):
374
+ raw.append(0) # filter type: None
375
+ raw.extend(pixels[y * width:(y + 1) * width])
376
+ idat = zlib.compress(bytes(raw), 9)
377
+
378
+ with open(path, "wb") as f:
379
+ f.write(b"\x89PNG\r\n\x1a\n")
380
+ f.write(_png_chunk(b"IHDR", ihdr))
381
+ f.write(_png_chunk(b"IDAT", idat))
382
+ f.write(_png_chunk(b"IEND", b""))
383
+
384
+
385
+ # ---------------------------------------------------------------------------
386
+ # CLI
387
+ # ---------------------------------------------------------------------------
388
+ def _version_string() -> str:
389
+ try:
390
+ return _pkg_version("flipshot")
391
+ except PackageNotFoundError:
392
+ return "0.0.0-dev"
393
+
394
+
395
+ def parse_args(argv=None) -> argparse.Namespace:
396
+ parser = argparse.ArgumentParser(
397
+ prog="flipshot",
398
+ description="Grab one frame from a Flipper Zero's screen and save it as a PNG.",
399
+ )
400
+ parser.add_argument(
401
+ "port",
402
+ nargs="?",
403
+ default=None,
404
+ help="Serial port to use (auto-detected if omitted), "
405
+ "e.g. /dev/cu.usbmodemflip_XXXX1",
406
+ )
407
+ parser.add_argument(
408
+ "output",
409
+ nargs="?",
410
+ default=None,
411
+ help="Output PNG path (default: flipshot-<device-name>-<timestamp>.png)",
412
+ )
413
+ parser.add_argument(
414
+ "--version", action="version", version=f"%(prog)s {_version_string()}"
415
+ )
416
+ return parser.parse_args(argv)
417
+
418
+
419
+ def main() -> int:
420
+ args = parse_args()
421
+
422
+ port = args.port
423
+ if port is None:
424
+ port = find_flipper_port()
425
+ if port is None:
426
+ quit_message(
427
+ "No Flipper Zero detected.\n"
428
+ "Connect it via USB, then run this script again.\n"
429
+ "Or pass the serial port explicitly, for example:\n"
430
+ " flipshot /dev/cu.usbmodemflip_YourName1"
431
+ )
432
+
433
+ out_path = args.output
434
+
435
+ print(f"Connecting to {port} ...")
436
+ try:
437
+ frame_data, device_name = grab_screen_frame(port)
438
+ except serial.SerialException as exc:
439
+ quit_message(
440
+ f"Could not open {port}: {exc}\n"
441
+ "Close qFlipper or any serial terminal using the Flipper, then retry."
442
+ )
443
+ except TimeoutError:
444
+ quit_message(
445
+ f"Timed out waiting for a response on {port}.\n"
446
+ "Check the USB cable, wake the Flipper, and make sure nothing else is using the port."
447
+ )
448
+ except RuntimeError as exc:
449
+ quit_message(str(exc))
450
+
451
+ if out_path is None:
452
+ out_path = default_output_path(device_name)
453
+ pixels = frame_to_pixels(frame_data)
454
+ save_png(out_path, pixels, SCREEN_W, SCREEN_H)
455
+ print(f"Saved native {SCREEN_W}x{SCREEN_H} PNG to {out_path}")
456
+ return 0
457
+
458
+
459
+ if __name__ == "__main__":
460
+ raise SystemExit(main())