gd-77_screen_grabber.py is the universal OpenGD77 screen grabber (despite the name): it talks over the USB CDC serial port, auto-detects the radio model and reads the MD-UV380's 160x128 RGB565 framebuffer, saving a PNG. Handy for capturing UI changes. gd-77_firmware_loader.py is the GD-77 (MK22) SGL loader, included for completeness (the MD-UV380 uses opengd77_stm32_firmware_loader.py). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
488 lines
16 KiB
Python
488 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
Copyright (C) 2019-2024 Daniel Caujolle-Bert, F1RMB
|
|
Roger Clark, VK3KYY / G4KYF
|
|
|
|
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions
|
|
are met:
|
|
|
|
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
|
|
|
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer
|
|
in the documentation and/or other materials provided with the distribution.
|
|
|
|
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived
|
|
from this software without specific prior written permission.
|
|
|
|
4. Use of this source code or binary releases for commercial purposes is strictly forbidden. This includes, without limitation,
|
|
incorporation in a commercial product or incorporation into a product or project which allows commercial use.
|
|
|
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
|
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
|
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
|
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
|
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
|
|
USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
|
|
####################################################################################
|
|
|
|
You also need python3-serial
|
|
On Fedora you need to install python3-pyserial.noarch (sudo dnf install python3-pyserial.noarch)
|
|
On archLinux, you need to install python3, pip, and some python packages (pacman -S python-pip && pip install --user pyserial pillow cimage)
|
|
|
|
On windows install pyserial, pillow and cimage (using pip install ...)
|
|
"""
|
|
|
|
from datetime import datetime
|
|
import time
|
|
import os.path
|
|
import ntpath
|
|
import getopt, sys
|
|
import serial
|
|
import platform
|
|
from ctypes import *
|
|
import enum
|
|
from PIL import Image
|
|
from PIL import ImageDraw, ImageColor
|
|
|
|
|
|
imgBuffer = [0x0] * 1024
|
|
MAX_TRANSFER_SIZE = 1024
|
|
|
|
# Default values
|
|
DEFAULT_SCALE = 2
|
|
DEFAULT_FOREGROUND = "#000000"
|
|
DEFAULT_BACKGROUND = "#99d9ea"
|
|
##
|
|
PROGRAM_VERSION = '0.0.4'
|
|
|
|
PlatformsNames = [ "GD-77", "GD-77S", "DM-1801", "RD-5R", "DM-1801A", "MD-9600", "MD-UV380", "MD-380", "DM-1701", "MD-2017", "DM-1701 (RGB)" ]
|
|
|
|
class PlatformModels(enum.Enum):
|
|
PLATFORM_UNKNOWN = -1
|
|
PLATFORM_GD77 = 0
|
|
PLATFORM_GD77S = 1
|
|
PLATFORM_DM1801 = 2
|
|
PLATFORM_RD5R = 3
|
|
PLATFORM_DM1801A = 4
|
|
PLATFORM_MD9600 = 5
|
|
PLATFORM_MDUV380 = 6
|
|
PLATFORM_MD380 = 7
|
|
PLATFORM_DM1701_BGR = 8
|
|
PLATFORM_MD2017 = 9
|
|
PLATFORM_DM1701_RGB = 10
|
|
|
|
def __int__(self):
|
|
return self.value
|
|
|
|
class RadioInfoFeatures(enum.Enum):
|
|
SCREEN_INVERTED = (1 << 0)
|
|
DMRID_USES_VOICE_PROMPTS = (1 << 1)
|
|
VOICE_PROMPTS_AVAILABLE = (1 << 2)
|
|
|
|
def __int__(self):
|
|
return self.value
|
|
|
|
class RadioInfoStruct(Structure):
|
|
_pack_ = 1
|
|
_fields_ = [('structVersion', c_uint),
|
|
('radioType', c_uint),
|
|
('gitRevision', c_char * 16),
|
|
('buildDateTime', c_char * 16),
|
|
('flashId', c_uint),
|
|
('features', c_ushort)]
|
|
|
|
platformModel = PlatformModels.PLATFORM_UNKNOWN
|
|
radioInfo = None;
|
|
|
|
###
|
|
#
|
|
###
|
|
def sendCommand(ser, command, x_or_command_option_number=0, y=0, iSize=0, alignment=0, isInverted=0, message=""):
|
|
sendbuffer = [0x0] * 64
|
|
readbuffer = [0x0] * 64
|
|
radioInfoReceived = False
|
|
totalLength = 0
|
|
radioInfoBuffer = []
|
|
bytesToSend = 2
|
|
|
|
sendbuffer[0] = ord('C')
|
|
sendbuffer[1] = command
|
|
|
|
if (command == 2):
|
|
sendbuffer[3] = y
|
|
sendbuffer[4] = iSize
|
|
sendbuffer[5] = alignment
|
|
sendbuffer[6] = isInverted
|
|
bytesToSend += (5 + min([len(message), 16]))
|
|
sendbuffer[7:] = str.encode(message)[0:(bytesToSend - 7)]
|
|
elif (command == 6):
|
|
#Special command
|
|
sendbuffer[2] = x_or_command_option_number
|
|
bytesToSend += 1;
|
|
|
|
ser.flush()
|
|
|
|
ret = ser.write(sendbuffer[0:bytesToSend])
|
|
if (ret != bytesToSend):
|
|
print("ERROR: write() wrote " + ret + " bytes")
|
|
return False
|
|
|
|
while (ser.in_waiting == 0):
|
|
time.sleep(0.2)
|
|
|
|
readbuffer = ser.read(ser.in_waiting)
|
|
|
|
return (readbuffer[0] == command)
|
|
|
|
###
|
|
# Check feature bit from RadioInfo's feature
|
|
###
|
|
def RadioInfoIsFeatureSet(feature):
|
|
v = int(radioInfo.features)
|
|
f = int(feature)
|
|
|
|
if ((v & f) != 0):
|
|
return True
|
|
|
|
return False
|
|
|
|
###
|
|
# Read the RadioInfo, then fill the global structure
|
|
###
|
|
def readRadioInfo(ser):
|
|
DataModeReadRadioInfo = 9
|
|
sendbuffer = [0x0] * 8
|
|
readbuffer = [0x0] * 64
|
|
radioInfoReceived = False
|
|
totalLength = 0
|
|
radioInfoBuffer = []
|
|
size = 8
|
|
|
|
ser.flush()
|
|
|
|
print(" - read RadioInfo...")
|
|
sendbuffer[0] = ord('R')
|
|
sendbuffer[1] = DataModeReadRadioInfo
|
|
sendbuffer[2] = 0
|
|
sendbuffer[3] = 0
|
|
sendbuffer[4] = 0
|
|
sendbuffer[5] = 0
|
|
sendbuffer[6] = ((size >> 8) & 0xFF);
|
|
sendbuffer[7] = ((size >> 0) & 0xFF);
|
|
|
|
ret = ser.write(sendbuffer)
|
|
if (ret != 8):
|
|
print("ERROR: write() wrote " + ret + " bytes")
|
|
return False
|
|
|
|
while (ser.in_waiting == 0):
|
|
time.sleep(0.2)
|
|
|
|
readbuffer = ser.read(ser.in_waiting)
|
|
|
|
header = ord('R')
|
|
|
|
if (readbuffer[0] == header):
|
|
totalLength = (readbuffer[1] << 8) + (readbuffer[2] << 0)
|
|
radioInfoBuffer[0:] = readbuffer[3:]
|
|
|
|
else:
|
|
return False
|
|
|
|
if (totalLength > 0):
|
|
## Check about RadioInfo version and upgrade if possible
|
|
## Latest version is 0x03
|
|
if (radioInfoBuffer[0] == 0x01):
|
|
radioInfoBuffer += [0x00, 0x00] ## features set to 0
|
|
elif (radioInfoBuffer[0] == 0x02):
|
|
radioInfoBuffer += [0x00]; ## convert old screenInverted to features
|
|
|
|
global radioInfo
|
|
radioInfo = RadioInfoStruct.from_buffer(bytearray(radioInfoBuffer))
|
|
|
|
##print(radioInfoBuffer)
|
|
#print(" * structVersion:", radioInfo.structVersion)
|
|
#print(" * radioType:", radioInfo.radioType)
|
|
#print(" * gitRevision:", radioInfo.gitRevision.decode("utf-8"))
|
|
#print(" * buildDateTime:", radioInfo.buildDateTime.decode("utf-8"))
|
|
#print(" * flashId:", hex(radioInfo.flashId))
|
|
#print(" * features:", hex(radioInfo.features))
|
|
|
|
global platformModel
|
|
platformModel = PlatformModels(radioInfo.radioType)
|
|
|
|
return True
|
|
|
|
return False
|
|
|
|
###
|
|
# Send the command to the GD-77 and read buffer back
|
|
###
|
|
def sendAndReceiveCommand(ser):
|
|
DataModeReadScreenGrab = 6
|
|
sendbuffer = [0x0] * 8
|
|
readbuffer = [0x0] * 64
|
|
currentDataAddressInTheRadio = 0
|
|
currentDataAddressInLocalBuffer = 0
|
|
totalSize = 1024
|
|
size = 1024
|
|
progress = 0
|
|
|
|
if (platformModel == PlatformModels.PLATFORM_MDUV380) or (platformModel == PlatformModels.PLATFORM_MD380) or (platformModel == PlatformModels.PLATFORM_DM1701_BGR) or (platformModel == PlatformModels.PLATFORM_DM1701_RGB) or (platformModel == PlatformModels.PLATFORM_MD2017):
|
|
global imgBuffer
|
|
totalSize = 160 * 128 * 2
|
|
imgBuffer = [0x0] * totalSize
|
|
|
|
ser.flush()
|
|
|
|
#
|
|
##
|
|
##
|
|
##
|
|
print(" - downloading from the {}...".format(PlatformsNames[int(platformModel)]))
|
|
while (size > 0):
|
|
if (size > MAX_TRANSFER_SIZE):
|
|
size = MAX_TRANSFER_SIZE;
|
|
|
|
sendbuffer[0] = ord('R')
|
|
sendbuffer[1] = DataModeReadScreenGrab
|
|
sendbuffer[2] = ((currentDataAddressInTheRadio >> 24) & 0xFF)
|
|
sendbuffer[3] = ((currentDataAddressInTheRadio >> 16) & 0xFF)
|
|
sendbuffer[4] = ((currentDataAddressInTheRadio >> 8) & 0xFF)
|
|
sendbuffer[5] = ((currentDataAddressInTheRadio >> 0) & 0xFF)
|
|
sendbuffer[6] = ((size >> 8) & 0xFF);
|
|
sendbuffer[7] = ((size >> 0) & 0xFF);
|
|
|
|
ret = ser.write(sendbuffer)
|
|
if (ret != 8):
|
|
print("ERROR: write() wrote " + ret + " bytes")
|
|
return False
|
|
|
|
while (ser.in_waiting == 0):
|
|
time.sleep(0.2)
|
|
|
|
readbuffer = ser.read(ser.in_waiting)
|
|
|
|
header = ord('R')
|
|
|
|
if (readbuffer[0] == header):
|
|
|
|
l = (readbuffer[1] << 8) + (readbuffer[2] << 0)
|
|
|
|
# something went wrong in the communication, keep the current data, flush the serial buffers
|
|
if (l > (len(readbuffer) - 3)):
|
|
ser.read(ser.in_waiting)
|
|
ser.flush()
|
|
ser.reset_input_buffer()
|
|
l = len(readbuffer) - 3
|
|
|
|
for i in range(0, l):
|
|
imgBuffer[currentDataAddressInLocalBuffer] = readbuffer[i + 3]
|
|
currentDataAddressInLocalBuffer += 1
|
|
|
|
progress = currentDataAddressInTheRadio * 100 // totalSize
|
|
print("\r - reading: " + str(progress) + "%", end='')
|
|
sys.stdout.flush()
|
|
|
|
currentDataAddressInTheRadio += l
|
|
else:
|
|
print("read stopped (error at " + str(currentDataAddressInTheRadio) + ")")
|
|
|
|
return False
|
|
|
|
size = totalSize - currentDataAddressInTheRadio
|
|
|
|
print("\r - reading: 100%")
|
|
return True
|
|
|
|
|
|
###
|
|
# Scale and save the downloaded image to a PNG file
|
|
###
|
|
def saveImage(filename, scale, foreground, background):
|
|
width = 128
|
|
height = 64
|
|
|
|
if (platformModel == PlatformModels.PLATFORM_MDUV380) or (platformModel == PlatformModels.PLATFORM_MD380) or (platformModel == PlatformModels.PLATFORM_DM1701_BGR) or (platformModel == PlatformModels.PLATFORM_DM1701_RGB) or (platformModel == PlatformModels.PLATFORM_MD2017):
|
|
width = 160
|
|
height = 128
|
|
img = Image.new('RGB', (width, height), background)
|
|
# Drawing context
|
|
d = ImageDraw.Draw(img)
|
|
|
|
index = 0
|
|
|
|
for y in range(0, height):
|
|
for x in range(0, width):
|
|
colour565 = (imgBuffer[index] << 8) + imgBuffer[index + 1];
|
|
|
|
red = 0
|
|
green = 0
|
|
blue = 0
|
|
|
|
# BGR565
|
|
if (platformModel == PlatformModels.PLATFORM_DM1701_BGR):
|
|
red = ((colour565 & 0x1f) << 3)
|
|
green = ((colour565 & 0x7e0) >> 3)
|
|
blue = ((colour565 & 0xf800) >> 8)
|
|
else: # RGB565
|
|
red = ((colour565 & 0xf800) >> 8)
|
|
green = ((colour565 & 0x7e0) >> 3)
|
|
blue = ((colour565 & 0x1f) << 3)
|
|
|
|
if (RadioInfoIsFeatureSet(RadioInfoFeatures.SCREEN_INVERTED) == True): ## screen is Inverted
|
|
red = (0xff - red)
|
|
green = (0xff - green)
|
|
blue = (0xff - blue)
|
|
|
|
d.point((x, y), (red, green, blue))
|
|
index += 2;
|
|
|
|
|
|
else: # 1-bit per pixel
|
|
# Image (scale 1:1)
|
|
img = Image.new('RGB', (width, height), background)
|
|
# Drawing context
|
|
d = ImageDraw.Draw(img)
|
|
|
|
for stripe in range(0, 8):
|
|
for column in range(0, width):
|
|
for line in range(0, 8):
|
|
if (((imgBuffer[(stripe * width) + column] >> line) & 0x01) != 0):
|
|
d.point((column, stripe * 8 + line), foreground)
|
|
|
|
|
|
print(" - saving " + filename + ".png")
|
|
if (scale == 1):
|
|
img.save(filename + '.png')
|
|
else:
|
|
rimg = img.resize((width * scale, height * scale), resample=Image.NEAREST)
|
|
rimg.save(filename + '.png')
|
|
|
|
|
|
###
|
|
# Display command line options
|
|
###
|
|
def usage():
|
|
print("GD-77 Screen Grabber v" + PROGRAM_VERSION)
|
|
print("Usage: " + ntpath.basename(sys.argv[0]) + " [OPTION]")
|
|
print("")
|
|
print(" -h, --help : Display this help text,")
|
|
print(" -d, --device=<device> : Use the specified device as serial port,")
|
|
print(" -s, --scale=v : Apply scale factor (1..x) [default: " + str(DEFAULT_SCALE) + "],")
|
|
print(" -o, --output=<filename> : Save the image in <filename>.png (without file extension),")
|
|
print(" -f, --foreground=#RRGGBB : Use specified color as foreground color [default: " + DEFAULT_FOREGROUND + "],")
|
|
print(" -b, --background=#RRGGBB : Use specified color as background color [default: " + DEFAULT_BACKGROUND + "].")
|
|
print("")
|
|
|
|
|
|
###
|
|
# main function
|
|
###
|
|
def main():
|
|
# Default tty
|
|
if (platform.system() == 'Windows'):
|
|
serialDev = "COM13"
|
|
else:
|
|
serialDev = "/dev/ttyACM0"
|
|
|
|
scale = DEFAULT_SCALE
|
|
foreground = DEFAULT_FOREGROUND
|
|
background = DEFAULT_BACKGROUND
|
|
dateTimeObj = datetime.now()
|
|
timestampStr = dateTimeObj.strftime("%Y-%m-%d_%H_%M_%S")
|
|
filename = "GD-77_screengrab-" + timestampStr
|
|
|
|
# Command line argument parsing
|
|
try:
|
|
opts, args = getopt.getopt(sys.argv[1:], "hd:s:o:f:b:", ["help", "device=", "scale=", "output=", "foreground=", "background="])
|
|
except getopt.GetoptError as err:
|
|
print(str(err))
|
|
usage()
|
|
sys.exit(2)
|
|
|
|
for opt, arg in opts:
|
|
if opt in ("-h", "--help"):
|
|
usage()
|
|
sys.exit(2)
|
|
elif opt in ("-d", "--device"):
|
|
serialDev = arg
|
|
elif opt in ("-s", "--scale"):
|
|
scale = int(arg)
|
|
if (scale < 1):
|
|
scale = 1
|
|
elif opt in ("-o", "--output"):
|
|
filename = arg
|
|
elif opt in ("-f", "--foreground"):
|
|
# Check color validity
|
|
try:
|
|
rgb = ImageColor.getrgb(arg)
|
|
except ValueError as err:
|
|
print("Color '" + arg +"' is invalid")
|
|
sys.exit(-3)
|
|
|
|
foreground = arg
|
|
elif opt in ("-b", "--background"):
|
|
# Check color validity
|
|
try:
|
|
rgb = ImageColor.getrgb(arg)
|
|
except ValueError as err:
|
|
print("Color '" + arg +"' is invalid")
|
|
sys.exit(-3)
|
|
|
|
background = arg
|
|
else:
|
|
assert False, "Unhandled option"
|
|
|
|
# Initialize Serial Port
|
|
ser = serial.Serial()
|
|
ser.port = serialDev
|
|
ser.baudrate = 115200
|
|
ser.bytesize = serial.EIGHTBITS
|
|
ser.parity = serial.PARITY_NONE
|
|
ser.stopbits = serial.STOPBITS_ONE
|
|
ser.timeout = 1000.0
|
|
#ser.xonxoff = 0
|
|
#ser.rtscts = 0
|
|
ser.write_timeout = 1000.0
|
|
|
|
if (os.path.isfile(filename + ".png") == True):
|
|
# Add timestamp to avoid file override
|
|
print("WARNING: the file '" + filename + ".png' already exists. Image will be saved in '" + filename + "-" + timestampStr + ".png'.")
|
|
filename += "-" + timestampStr
|
|
|
|
try:
|
|
ser.open()
|
|
except serial.SerialException as err:
|
|
print(str(err))
|
|
sys.exit(1)
|
|
|
|
sendCommand(ser, 254)
|
|
print("Save screen image:")
|
|
if (readRadioInfo(ser) == True):
|
|
if (platformModel == PlatformModels.PLATFORM_UNKNOWN):
|
|
print("Failure: unsupported platform.")
|
|
sys.exit(1)
|
|
|
|
print(" * Platform is:", PlatformsNames[int(platformModel)])
|
|
|
|
if (sendAndReceiveCommand(ser) == True):
|
|
saveImage(filename, scale, foreground, background)
|
|
print("Done.")
|
|
else:
|
|
print("Failure")
|
|
else:
|
|
print("Failure")
|
|
|
|
if (ser.is_open):
|
|
sendCommand(ser, 7)
|
|
ser.close()
|
|
|
|
|
|
###
|
|
# Calling main function
|
|
###
|
|
main()
|
|
sys.exit(0)
|