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 +22 -0
- flipshot-1.0.0/PKG-INFO +81 -0
- flipshot-1.0.0/README.md +63 -0
- flipshot-1.0.0/pyproject.toml +36 -0
- flipshot-1.0.0/setup.cfg +4 -0
- flipshot-1.0.0/src/flipshot.egg-info/PKG-INFO +81 -0
- flipshot-1.0.0/src/flipshot.egg-info/SOURCES.txt +10 -0
- flipshot-1.0.0/src/flipshot.egg-info/dependency_links.txt +1 -0
- flipshot-1.0.0/src/flipshot.egg-info/entry_points.txt +2 -0
- flipshot-1.0.0/src/flipshot.egg-info/requires.txt +1 -0
- flipshot-1.0.0/src/flipshot.egg-info/top_level.txt +1 -0
- flipshot-1.0.0/src/flipshot.py +460 -0
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.
|
flipshot-1.0.0/PKG-INFO
ADDED
|
@@ -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.
|
flipshot-1.0.0/README.md
ADDED
|
@@ -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"
|
flipshot-1.0.0/setup.cfg
ADDED
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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())
|