Using a SONY QX10 (and QX100) lenscamera with L5 possible!

(short version: possible, usable, can be made better and more usable by community)

So… I like to have a camera on my L5 (or any phone) to take a quick pic. But I also like to get a bit better pics and only the high end phones do those, compared to actual cameras. Two different needs. When I originally awaited L5, I came across the Sony DSC-QX10 and DSC-QX100 lens cameras (possibly also QX30 and QX1, but those are bigger, less convenient): 18Mpix, optical zoom lenses that have a battery, memorycard and a wifi (and micro usb for data and charging, can use OTG adapter to DL pics to phone when not using camera). They are some ten eight-ish years old but actually pretty good quality and the range with bigger (than phones) optics are from a different world. Bigger sensor too (not referring to megapixels but noise ratio and quality, although more mpix than L5) - although, the current highends and their algorithms are comparable IMHO (lots has happened in a decade).



Anyway, I wanted to see if this was an option - especially since some holiday travel is in the near future - and got one rather cheap second hand. And wow, who ever at Sony designed the thing, knew back then what the measurements for L5 would be. Or L5 was designed to perfectly fit the clamp that the QX has. Someone earned an upvote for that :+1: But if you’re using a case/shell, it won’t fit (L5 is Max width and thickness) [edit: it is possible to force the clamp even on to a case width but not recommended - small adjustments to case would solve this]. On the other hand, it should be simple to add the required holes on the back of a case design to replace the removable clamp (and probably even save a few mm on thickness plus some airvent holes).


The hard part - and what I’d say still needs a bit of work - was to find software to connect the QX to L5. There have been scripts to do that for linux in the past but for someone not fluent in python, it was a hurdle. There are a few scripts (some which have actual action buttons for stuff) that use the open api but all seemed to be for python2 and/or pyqt4, which are not supported and available on L5. Luckily one was made with python3 and I managed to figure out to make enough of an upgrade from pyqt4 to pyqt5 (very very little actually, just added “from PyQt5.QtWidgets import *” and edited the view size) to make it work. Well, it works enough for minimal use: it gives a viewfinder picture. [edit to add: the QX does not have to be attached to the phone to work - they can be quite a distance apart - but for now, the remote shutter is not there, nor other still/video controls]


So, yay, anyone can use a QX10 (or QX100, a bit different specs but same control api) while having a view with their L5 locally (wifi is from device to device only - not net via wifi when taking pics). Yes, I’m using the new wlan card. I’ll add the script below. Original script can be found here (only the .py is needed).

Now, I don’t have the skills, but if someone form the community would be up to it, there are example scripts (python2 mostly) that give some pointers on what to do (1, 2).

While connecting, it takes a minute and for some reason the camera takes three pictures that it downloads to L5 (directory of the script). But other pics that you take are recorded on the QX memory card as you use the physical buttons on its side (zoom +/- and shutter - which can be seen as a bonus, since it’s probably faster and more reliable that way. That’s not to say that those same buttons shouldn’t be on the software too. The window also closes poorly, not like L5 apps do. There are a number of api options that can be found from the manual, that haven’t been implemented (see for instance the original code’s github).

Currently I’m cleaning up my install of the scripts to a designated folder and starting to look at how to set up a desktop shortcut. For now, this “PoC” works for me. I hope this interests others too.

sony-qx-controller-L5.py (original):

#!/usr/bin/env python3

# Script for managing Sony QX10 or QX100 from Librem5 linux phone.
# Supports AUTHORIZATION (!!!), which allows to use a lot of undocumented commands (such as setStillSize and others).

# Before using this script: manually connect to Wi-Fi; set up IP 10.0.1.1, mask 255.0.0.0 (and if that's not enough - default gateway: 10.0.0.1).
# Note. Password can be taken from a text file in the internal memory of the device, connect it with USB and switch on for accessing this memory.

# This script depended on PyQt4 for displaying liveview. It has been updated to use pyqt5. Original script: github.com/Tsar/sony_qx_controller

import sys, json, time
import http.client, urllib.parse
import threading
import base64, hashlib

from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
from PyQt5.QtGui import *

lock = threading.Lock()

app = QApplication(sys.argv)
image = QImage()

class ImageDisplay(QLabel):
    def __init__(self):
        QLabel.__init__(self)

    def paintEvent(self, event):
        global lock
        lock.acquire()
        try:
            self.setPixmap(QPixmap.fromImage(image))
        finally:
            lock.release()
        QLabel.paintEvent(self, event)

imgDisplay = ImageDisplay()
imgDisplay.setMinimumSize(320, 240)
imgDisplay.show()

pId = 0
headers = {"Content-type": "text/plain", "Accept": "*/*", "X-Requested-With": "com.sony.playmemories.mobile"}

AUTH_CONST_STRING = "90adc8515a40558968fe8318b5b023fdd48d3828a2dda8905f3b93a3cd8e58dc"
METHODS_TO_ENABLE = "camera/setFlashMode:camera/getFlashMode:camera/getSupportedFlashMode:camera/getAvailableFlashMode:camera/setExposureCompensation:camera/getExposureCompensation:camera/getSupportedExposureCompensation:camera/getAvailableExposureCompensation:camera/setSteadyMode:camera/getSteadyMode:camera/getSupportedSteadyMode:camera/getAvailableSteadyMode:camera/setViewAngle:camera/getViewAngle:camera/getSupportedViewAngle:camera/getAvailableViewAngle:camera/setMovieQuality:camera/getMovieQuality:camera/getSupportedMovieQuality:camera/getAvailableMovieQuality:camera/setFocusMode:camera/getFocusMode:camera/getSupportedFocusMode:camera/getAvailableFocusMode:camera/setStillSize:camera/getStillSize:camera/getSupportedStillSize:camera/getAvailableStillSize:camera/setBeepMode:camera/getBeepMode:camera/getSupportedBeepMode:camera/getAvailableBeepMode:camera/setCameraFunction:camera/getCameraFunction:camera/getSupportedCameraFunction:camera/getAvailableCameraFunction:camera/setLiveviewSize:camera/getLiveviewSize:camera/getSupportedLiveviewSize:camera/getAvailableLiveviewSize:camera/setTouchAFPosition:camera/getTouchAFPosition:camera/cancelTouchAFPosition:camera/setFNumber:camera/getFNumber:camera/getSupportedFNumber:camera/getAvailableFNumber:camera/setShutterSpeed:camera/getShutterSpeed:camera/getSupportedShutterSpeed:camera/getAvailableShutterSpeed:camera/setIsoSpeedRate:camera/getIsoSpeedRate:camera/getSupportedIsoSpeedRate:camera/getAvailableIsoSpeedRate:camera/setExposureMode:camera/getExposureMode:camera/getSupportedExposureMode:camera/getAvailableExposureMode:camera/setWhiteBalance:camera/getWhiteBalance:camera/getSupportedWhiteBalance:camera/getAvailableWhiteBalance:camera/setProgramShift:camera/getSupportedProgramShift:camera/getStorageInformation:camera/startLiveviewWithSize:camera/startIntervalStillRec:camera/stopIntervalStillRec:camera/actFormatStorage:system/setCurrentTime"

def postRequest(conn, target, req):
    global pId
    pId += 1
    req["id"] = pId
    print("REQUEST  [%s]: " % target, end = "")
    print(req)
    conn.request("POST", "/sony/" + target, json.dumps(req), headers)
    response = conn.getresponse()
    print("RESPONSE [%s]: " % target, end = "")
    #print(response.status, response.reason)
    data = json.loads(response.read().decode("UTF-8"))
    print(data)
    if data["id"] != pId:
        print("FATAL ERROR: Response id does not match")
        return {}
    if "error" in data:
        print("WARNING: Response contains error code: %d; error message: [%s]" % tuple(data["error"]))
    print("")
    return data

def exitWithError(conn, message):
    print("ERROR: %s" % message)
    conn.close()
    sys.exit(1)

def parseUrl(url):
    parsedUrl = urllib.parse.urlparse(url)
    return parsedUrl.hostname, parsedUrl.port, parsedUrl.path + "?" + parsedUrl.query, parsedUrl.path[1:]

def downloadImage(url):
    host, port, address, img_name = parseUrl(url)
    conn2 = http.client.HTTPConnection(host, port)
    conn2.request("GET", address)
    response = conn2.getresponse()
    if response.status == 200:
        with open(img_name, "wb") as img:
            img.write(response.read())
    else:
        print("ERROR: Could not download picture, error = [%d %s]" % (response.status, response.reason))

#def symb5(c):
#    s = str(c)
#    while len(s) < 5:
#        s = "0" + s
#    return s

def liveviewFromUrl(url):
    global image
    global lock
    host, port, address, img_name = parseUrl(url)
    conn3 = http.client.HTTPConnection(host, port)
    conn3.request("GET", address)
    response = conn3.getresponse()
    #flow = open("liveview", "wb")
    if response.status == 200:
        buf = b''
        c = 0
        while not response.closed:
            nextPart = response.read(1024)
            #flow.write(nextPart)
            #flow.flush()

            # TODO: It would be better to use description from the documentation (page 51) for parsing liveview stream
            jpegStart = nextPart.find(b'\xFF\xD8\xFF')
            jpegEnd = nextPart.find(b'\xFF\xD9')
            if jpegEnd != -1:
                c += 1
                buf += nextPart[:jpegEnd + 2]
                #with open("live_" + symb5(c) + ".jpg", "wb") as liveImg:
                #    liveImg.write(buf)
                lock.acquire()
                try:
                    image.loadFromData(buf)
                finally:
                    lock.release()
            if jpegStart != -1:
                buf = nextPart[jpegStart:]
            else:
                buf += nextPart

def communicationThread():
    #target = "/sony/camera"
    #target = "/sony/system"
    #target = "/sony/accessControl"

    #req = {"method": "getVersions", "params": [], "id": 1}
    #req = {"method": "getApplicationInfo", "params": [], "id": 2, "version": "1.0"}
    #req = {"method": "getEvent", "params": [False], "id": 3, "version": "1.0"}        # (!!!) get method list
    #req = {"method": "getEvent", "params": [True], "id": 4, "version": "1.0"}
    #req = {"method": "getMethodTypes", "params": ["1.0"], "id": 8, "version": "1.0"}

    conn = http.client.HTTPConnection("10.0.0.1", 10000)

    resp = postRequest(conn, "camera", {"method": "getVersions", "params": []})
    if resp["result"][0][0] != "1.0":
        exitWithError(conn, "Unsupported version")

    resp = postRequest(conn, "accessControl", {"method": "actEnableMethods", "params": [{"methods": "", "developerName": "", "developerID": "", "sg": ""}], "version": "1.0"})
    dg = resp["result"][0]["dg"]

    h = hashlib.sha256()
    h.update(bytes(AUTH_CONST_STRING + dg, "UTF-8"))
    sg = base64.b64encode(h.digest()).decode("UTF-8")

    resp = postRequest(conn, "accessControl", {"method": "actEnableMethods", "params": [{"methods": METHODS_TO_ENABLE, "developerName": "Sony Corporation", "developerID": "7DED695E-75AC-4ea9-8A85-E5F8CA0AF2F3", "sg": sg}], "version": "1.0"})

    resp = postRequest(conn, "system", {"method": "getMethodTypes", "params": ["1.0"], "version": "1.0"})
    resp = postRequest(conn, "accessControl", {"method": "getMethodTypes", "params": ["1.0"], "version": "1.0"})

    resp = postRequest(conn, "camera", {"method": "getStillSize", "params": [], "version": "1.0"})
    #resp = postRequest(conn, "camera", {"method": "getSupportedStillSize", "params": [], "version": "1.0"})
    #resp = postRequest(conn, "camera", {"method": "getAvailableStillSize", "params": [], "version": "1.0"})

    #resp = postRequest(conn, "camera", {"method": "setStillSize", "params": ["20M", "3:2"], "version": "1.0"})

    resp = postRequest(conn, "camera", {"method": "setFocusMode", "params": ["AF-S"], "version": "1.0"})
    resp = postRequest(conn, "camera", {"method": "getFocusMode", "params": [], "version": "1.0"})

    resp = postRequest(conn, "camera", {"method": "stopLiveview", "params": [], "version": "1.0"})

    resp = postRequest(conn, "camera", {"method": "setPostviewImageSize", "params": ["Original"], "version": "1.0"})
    while "error" in resp:
        resp = postRequest(conn, "camera", {"method": "setPostviewImageSize", "params": ["Original"], "version": "1.0"})
    resp = postRequest(conn, "camera", {"method": "getPostviewImageSize", "params": [], "version": "1.0"})

    resp = postRequest(conn, "camera", {"method": "actTakePicture", "params": [], "version": "1.0"})
    downloadImage(resp["result"][0][0])

    resp = postRequest(conn, "camera", {"method": "setPostviewImageSize", "params": ["2M"], "version": "1.0"})
    while "error" in resp:
        resp = postRequest(conn, "camera", {"method": "setPostviewImageSize", "params": ["2M"], "version": "1.0"})
    resp = postRequest(conn, "camera", {"method": "getPostviewImageSize", "params": [], "version": "1.0"})

    resp = postRequest(conn, "camera", {"method": "actTakePicture", "params": [], "version": "1.0"})
    downloadImage(resp["result"][0][0])

    resp = postRequest(conn, "camera", {"method": "setPostviewImageSize", "params": ["Original"], "version": "1.0"})
    while "error" in resp:
        resp = postRequest(conn, "camera", {"method": "setPostviewImageSize", "params": ["Original"], "version": "1.0"})
    resp = postRequest(conn, "camera", {"method": "getPostviewImageSize", "params": [], "version": "1.0"})

    resp = postRequest(conn, "camera", {"method": "actTakePicture", "params": [], "version": "1.0"})
    downloadImage(resp["result"][0][0])

    resp = postRequest(conn, "camera", {"method": "getAvailableFocusMode", "params": [], "version": "1.0"})

    resp = postRequest(conn, "camera", {"method": "getSupportedFocusMode", "params": [], "version": "1.0"})

    resp = postRequest(conn, "camera", {"method": "getTouchAFPosition", "params": [], "version": "1.0"})

    resp = postRequest(conn, "camera", {"method": "getSupportedFNumber", "params": [], "version": "1.0"})

    #resp = postRequest(conn, "camera", {"method": "setFocusMode", "params": ["MF"], "version": "1.0"})
    #resp = postRequest(conn, "camera", {"method": "getFocusMode", "params": [], "version": "1.0"})

    resp = postRequest(conn, "camera", {"method": "getEvent", "params": [False], "version": "1.0"})

    resp = postRequest(conn, "camera", {"method": "startLiveview", "params": [], "version": "1.0"})
    liveview = threading.Thread(target = liveviewFromUrl, args = (resp["result"][0],))
    liveview.start()

    resp = postRequest(conn, "camera", {"method": "actZoom", "params": ["in", "start"], "version": "1.0"})
    time.sleep(2)
    resp = postRequest(conn, "camera", {"method": "actZoom", "params": ["in", "stop"], "version": "1.0"})

    time.sleep(3)

    resp = postRequest(conn, "camera", {"method": "actZoom", "params": ["out", "start"], "version": "1.0"})
    time.sleep(2.5)
    resp = postRequest(conn, "camera", {"method": "actZoom", "params": ["out", "stop"], "version": "1.0"})

    conn.close()

if __name__ == "__main__":
    communication = threading.Thread(target = communicationThread)
    communication.start()

    sys.exit(app.exec_())
25 Likes

Some notes for possible future improvements:

  • starting the script takes three unwanted pics automatically and blindly (before view)
  • saves both jpg and dng from all pics - maybe just jpg on most would be convenient
  • image from camera is larger than viewport/window, so it gets cropped - still usable but not ideal
  • needs also remote shutter button on window for more elaborate use cases (camera at a distance, like selfie stick or stand)
  • video recording mode enabling would be nice (as a webcam too?)
  • re-naming of files with date and time would be ideal (they come from camera, not script but maybe…)
  • close and exit cleanly - maybe close wifi availability of L5 too (security by default preference)?
  • selection of frame size would be bonus (why use anything else than full?)
  • battery level indication would be bonus (now can just estimate from number of photos)
  • have an option for L5 camera to take simultaneous picture (a general wider image vs. sony zoomed image)
  • select save folder
  • option for location data to metadata (select what metadata is used)
  • gif recording?
3 Likes

I played around with that old PoC for a while but not got some time in my hands and polished the script enough to dare paste it for anyone to use (the polishing part was an interesting one as I’ve never worked with python and now it’s gone from 381 lines and 15729 characters to 1158 and 51250 with comments). If anyone wants a camera user interface that’s made for and tested to work with Librem 5 (testing is ongoing and it’s likely I’ll edit and tweak this post in the coming days), here’s one, although it should run in other (linux) phones too. It’s easy to run when you set up a .desktop and an icon to sun it like any normal GUI app, you just have to have the camera connected via wifi first and preferably set zoom to 150% and use Mobile-Settings compositor setting on it.

This QX-Cam script got zooms and ISO values. I added a toggle for helper sights and center dot. There are options for timedelays, intervals and and how many pics you want the camera to take (while you go for some tea or something). And based on some old thread here, I even made a more complicated save folder setting dialog and file renaming to my liking. And a bunch of checks and warnings, so you get feedback from it. Script code is at the end fo this post.


It’s definitely better quality cam than the one in the phone, but it’s a bit slow due to the wifi/http-based connection (5m between cam and phone seems possible). I may start a “fork” of this later on to explore alternatives. This is intentionally just a straight forward camera with a layout that has buttons that adult fingers should easily use. It’s just for landscape use (havent’ implemented portrait mode rotation or anything like that).


(the lighting on my desktop was bad, that’s why it looks a bit blue)

ZUR_LENSMOUNT-1

Script code here
#!/usr/bin/env python3

# QX-Cam is a script for a simple camera GUI for using Sony QX10 or QX100 from touch screen of Librem5 linux phone (https://puri.sm/products/librem-5/) by JR-Fi @forums.purism.sm. It should mostly work on other platforms as well. 

### USING THIS SCRIPT

# Before using this script: manually connect to Wi-Fi; set up IP 10.0.1.1, mask 255.0.0.0 (and if that's not enough - default gateway: 10.0.0.1), The camera is hidden, ssid is something like "DIRECT-XXXX:QX10".
# Note that password can be taken from a text file in the internal memory of the device if you don't have it. Connect to it with USB and switch on for accessing this memory. 

# For best GUI results, in phosh, use Mobile Settings -> Compositor scale down setting to fit it to screen (setting available when script is running). 
# For convenience, an appropriate QX-controller.desktop file should be added to ~/.local/share/applications and a suitable .png icon to ~/.local/share/Icons to create desktop shortcut to start the python3 script like a normal GUI app (see for instance https://source.puri.sm/Librem5/community-wiki/-/wikis/Tips%20&%20Tricks#creating-a-shortcut-to-execute-a-terminal-command for the file contents - it's just 8 short lines). 

### SCRIPT DEVELOPMENT NOTES

# This script is an updated version to PyQT5, cleaned up and usability is tweaked specifically for Librem 5 (screen size 720×1440 view)
# This script based some of the ideas in https://github.com/avetics/qx100 which is based on https://github.com/Tsar/sony_qx_controller
# Published 2025 under Creative Commons license CC-BY-SA unless other preceding rights apply. Include the all the above comment lines in any subsequent versions.

# Supports "dev authorization", which should allow to use a lot of undocumented commands (such as setStillSize and others), but may be deprecated as no joy (at least yet).

# Version comments:
# v1: change PyQT4 to PyQT5, connection test, set desktop side 
# v2: troubleshoot camera view, crashes, add error prints
# v3: change camera view streaming method, layout structure, file save handling
# v4: add video recording, set limit to minimum free space on disc
# v5: remove overlaygrids (for now) for simple side markings, thread for better responsiveness, more robust error handling, better GUI messages 
# v6: layout tweaks, fixes to view and functions
# v7: attempts at liveview connection retries and keepalive, wifi connectivity from camera GUI but removed (maybe to-do later)
# v8: battery indicator not possible, selectors for aperture, shutter and ISO tested (only ISO works), try fixing video recording (use liveview stream for now)
# v9 for to "QX-Cam" (the light version, basic photo camera) and "QX-Controller" (maybe develop further and add features) from earlier v8 iteration. QX-Cam: simple camera GUI, remove old code and extra functionalities that don't work well. Dark background. Add view toggle and sights/helperlines/dot (also starts liveview). One more re-try limiter added. Usable basic model.
# v10: timer based settings, layout fixes and improvements (still only horixontal), ISO fixed, save folder dialog and settings logic
# at this point script was loaded to https://forums.puri.sm/t/using-a-sony-qx10-and-qx100-lenscamera-with-l5-possible/19660/2 
# Statistic: from 381 lines and 15729 characters to 1158 and 51250 with comments, most code rewritten or refractored.

# Available APIs (from getAvailableApiList): ['getMethodTypes', 'getAvailableApiList', 'setShootMode', 'getShootMode', 'getSupportedShootMode', 'getAvailableShootMode', 'setSelfTimer', 'getSelfTimer', 'getSupportedSelfTimer', 'getAvailableSelfTimer', 'setPostviewImageSize', 'getPostviewImageSize', 'getSupportedPostviewImageSize', 'getAvailablePostviewImageSize', 'startLiveview', 'stopLiveview', 'actTakePicture', 'startMovieRec', 'stopMovieRec', 'awaitTakePicture', 'actZoom', 'setExposureMode', 'getExposureMode', 'getSupportedExposureMode', 'getAvailableExposureMode', 'setBeepMode', 'getBeepMode', 'getSupportedBeepMode', 'getAvailableBeepMode', 'setCameraFunction', 'getCameraFunction', 'getSupportedCameraFunction', 'getAvailableCameraFunction', 'setStillSize', 'getStillSize', 'getSupportedStillSize', 'getAvailableStillSize', 'actFormatStorage', 'getStorageInformation', 'setTouchAFPosition', 'cancelTouchAFPosition', 'getTouchAFPosition', 'setExposureCompensation', 'getExposureCompensation', 'getSupportedExposureCompensation', 'getAvailableExposureCompensation', 'setWhiteBalance', 'getWhiteBalance', 'getSupportedWhiteBalance', 'getAvailableWhiteBalance', 'setIsoSpeedRate', 'getIsoSpeedRate', 'getSupportedIsoSpeedRate', 'getAvailableIsoSpeedRate', 'actHalfPressShutter', 'cancelHalfPressShutter', 'getApplicationInfo', 'getVersions', 'getEvent']

import sys, json, time
import http.client, urllib.parse
import threading
import base64, hashlib
import requests
import os
import shutil
import subprocess

from PyQt5.QtWidgets import (
    QApplication, QLabel, QPushButton, QComboBox, QDialog, QListWidget, QListWidgetItem,
    QGridLayout, QHBoxLayout, QVBoxLayout, QRadioButton, QLineEdit, QCheckBox, QDialogButtonBox, 
    QSizePolicy, QWidget, QSpacerItem, QFileDialog, QScrollArea
)
from PyQt5.QtCore import Qt, QTimer
from PyQt5.QtGui import QImage, QPixmap, QPainter, QPen, QFont, QColor


CAMERA_IP = "10.0.0.1"
CAMERA_PORT = 10000

last_action_time = time.time()
running = True
liveview_thread = None
lock = threading.Lock()
image = QImage()


class ImageDisplay(QLabel):
    def __init__(self):
        super().__init__()
        self.showOverlay = False  # New flag

    def toggleOverlay(self):
        self.showOverlay = not self.showOverlay
        self.update()

    def paintEvent(self, event):
        global lock
        with lock:
            if image.isNull():
                return
            img_copy = image.copy()
            if self.showOverlay:
                qp = QPainter(img_copy)
                pen = QPen(QColor("red"), 3)
                qp.setPen(pen)
                w, h = img_copy.width(), img_copy.height()
                thirds_x = [w // 3, w // 2, 2 * w // 3]
                thirds_y = [h // 3, h // 2, 2 * h // 3]
                line_len = 40
                mid_line_len = line_len // 2
                for x in thirds_x:
                    length = mid_line_len if x == w // 2 else line_len
                    qp.drawLine(x, 0, x, length)
                    qp.drawLine(x, h - length, x, h)
                for y in thirds_y:
                    length = mid_line_len if y == h // 2 else line_len
                    qp.drawLine(0, y, length, y)
                    qp.drawLine(w - length, y, w, y)
                center_x = w // 2
                center_y = h // 2
                dot_radius = 4
                qp.setBrush(QColor("red"))
                qp.drawEllipse(center_x - dot_radius, center_y - dot_radius, dot_radius * 2, dot_radius * 2)
                qp.end()
            self.setPixmap(QPixmap.fromImage(img_copy))
        super().paintEvent(event)

pId = 0
headers = {
    "Content-type": "text/plain",
    "Accept": "*/*",
    "X-Requested-With": "com.sony.playmemories.mobile"
}
AUTH_CONST_STRING = "90adc8515a40558968fe8318b5b023fdd48d3828a2dda8905f3b93a3cd8e58dc"
# METHODS_TO_ENABLE = "" <-- may need content or not needed?


def postRequest(conn, target, req, retries=2):
    global pId
    pId += 1
    req["id"] = pId
    for attempt in range(retries):
        try:
            conn.request("POST", f"/sony/{target}", json.dumps(req), headers)
            response = conn.getresponse()
            data = json.loads(response.read().decode("UTF-8"))
            if data.get("id") != pId:
                print("Warning: Response ID mismatch")
                return {}
            if "error" in data:
                print(f"Camera returned error: {data['error']}")
                return {}
            return data
        except Exception as e:
            print(f"Request to {target} failed (attempt {attempt + 1}/{retries}): {e}")
            time.sleep(2 ** attempt)  # exponential backoff
    print(f"All {retries} attempts to {target} failed.")
    return {}


def parseUrl(url):
    parsedUrl = urllib.parse.urlparse(url)
    return parsedUrl.hostname, parsedUrl.port, parsedUrl.path + "?" + parsedUrl.query, parsedUrl.path[1:]



def liveviewFromUrl(url):
    global image, lock, running, last_action_time
    print("Starting liveview thread with Sony QX protocol parsing...")

    def is_camera_alive():
        try:
            ping = requests.get(f"http://{CAMERA_IP}:{CAMERA_PORT}", timeout=2)
            return ping.status_code == 200
        except:
            return False

    while running:
        try:
            print("Attempting to connect to liveview stream...")
            with requests.get(url, stream=True, timeout=5) as r:
                r.raise_for_status()
                stream = r.raw
                while running:
                    # Read 8-byte common header
                    common_header = stream.read(8)
                    if len(common_header) < 8 or common_header[0] != 0xFF:
                        print("Invalid or incomplete common header.")
                        continue

                    # Read 128-byte payload header
                    payload_header = stream.read(128)
                    if len(payload_header) < 128 or payload_header[:4] != b'\x24\x35\x68\x79':
                        print("Invalid or incomplete payload header.")
                        continue

                    # Extract JPEG size (3 bytes) and padding size (1 byte)
                    jpeg_size = int.from_bytes(payload_header[4:7], byteorder='big')
                    padding_size = payload_header[7]

                    # Read JPEG data
                    jpeg_data = stream.read(jpeg_size)
                    if len(jpeg_data) < jpeg_size:
                        print("Incomplete JPEG data.")
                        continue

                    # Skip padding
                    if padding_size > 0:
                        stream.read(padding_size)

                    # Load image
                    with lock:
                        success = image.loadFromData(jpeg_data)
                        if success:
                            last_action_time = time.time()
                        else:
                            print("Failed to decode JPEG frame.")

        except Exception as e:
            print("Liveview stream error:", e)
            with lock:
                image = QImage()
            app = QApplication.instance()
            if app:
                for widget in app.topLevelWidgets():
                    if hasattr(widget, "imgDisplay"):
                        QTimer.singleShot(0, widget.imgDisplay.update)
                    if hasattr(widget, "updateConnectionStatus"):
                        QTimer.singleShot(0, lambda w=widget: w.updateConnectionStatus(wifi_ok=True, camera_ok=False))
                    if hasattr(widget, "logMessage"):
                        QTimer.singleShot(0, lambda w=widget: w.logMessage("Liveview stream error. Retrying...", is_error=True))
            time.sleep(2)




def communicationThread():
    global running
    app = QApplication.instance()  # Get once and reuse

    # Early ping check to avoid blocking GUI interaction if camera is unreachable
    try:
        subprocess.run(["ping", "-c", "1", CAMERA_IP],
                       stdout=subprocess.DEVNULL,
                       stderr=subprocess.DEVNULL,
                       timeout=3,
                       check=True)
    except Exception:
        print("Camera not reachable. Skipping connection attempt.")
        if app:
            for widget in app.topLevelWidgets():
                if hasattr(widget, "updateConnectionStatus"):
                    QTimer.singleShot(0, lambda w=widget: w.updateConnectionStatus(wifi_ok=False, camera_ok=False))
                if hasattr(widget, "logMessage"):
                    QTimer.singleShot(0, lambda w=widget: w.logMessage("Camera not reachable. Waiting for connection...", is_error=True))
        return

    while running:
        print("Connecting to camera...")
        conn = http.client.HTTPConnection(CAMERA_IP, CAMERA_PORT, timeout=5)


        # Step 1: Get camera version
        resp = postRequest(conn, "camera", {"method": "getVersions", "params": []}, retries=1)
        print("Camera version response:", resp)
        if "result" not in resp or not resp["result"] or resp["result"][0][0] != "1.0":
            print("Unsupported or missing camera version.")
            return

        # not needed - got the list, v10
        # checkAvailableApis(conn)

        # Step 2: Initial authorization to get 'dg'
        resp = postRequest(conn, "accessControl", {
            "method": "actEnableMethods",
            "params": [{"methods": "", "developerName": "", "developerID": "", "sg": ""}],
            "version": "1.0"
        }, retries=1)

        try:
            dg = resp["result"][0]["dg"]
            h = hashlib.sha256()
            h.update(bytes(AUTH_CONST_STRING + dg, "UTF-8"))
            sg = base64.b64encode(h.digest()).decode("UTF-8")
        except Exception as e:
            print("Authorization failed:", e)
            conn.close()
            return

        # Step 3: Developer authorization with sg
        resp = postRequest(conn, "accessControl", {
            "method": "actEnableMethods",
            "params": [{"methods": "", "developerName": "dev", "developerID": "dev", "sg": sg}],

            "version": "1.0"
        }, retries=1)

        print("Developer authorization response:", resp)

     
        # Step 4: Start liveview
        print("Attempting to start liveview...")
        resp = postRequest(conn, "camera", {"method": "startLiveview", "params": [], "version": "1.0"})
        print("Liveview start response:", resp)

        if "result" in resp and resp["result"]:
            liveview_url = resp["result"][0]
            print("Liveview URL received:", liveview_url)

            # Update connection status to OK
            if app:
                for widget in app.topLevelWidgets():
                    if hasattr(widget, "updateConnectionStatus"):
                        QTimer.singleShot(0, lambda w=widget: w.updateConnectionStatus(wifi_ok=True, camera_ok=True))

            global liveview_thread
            if liveview_thread and liveview_thread.is_alive():
                print("Liveview thread already running.")
            else:
                liveview_thread = threading.Thread(target=liveviewFromUrl, args=(liveview_url,))
                liveview_thread.start()
                print("Liveview thread started.")
                              
                # Schedule ISO and WB setup after liveview starts
                if app:
                    for widget in app.topLevelWidgets():
                        if isinstance(widget, Form):
                            def safePopulateControls(form_widget):
                                try:
                                    form_widget.populateExposureControls()
                                except Exception as e:
                                    form_widget.logMessage(f"ISO init failed: {e}", is_error=True)

                            QTimer.singleShot(1400, lambda w=widget: safePopulateControls(w))

        else:
            print("Failed to start liveview. Full response:", resp)
            if app:
                for widget in app.topLevelWidgets():
                    if hasattr(widget, "updateConnectionStatus"):
                        QTimer.singleShot(0, lambda w=widget: w.updateConnectionStatus(wifi_ok=True, camera_ok=False))
            return


class Form(QDialog):
    def __init__(self, parent=None):
        super().__init__(parent)

        self.config_path = os.path.expanduser("~/.qx-cam_config.json")
        self.defaultSaveFolder = os.path.expanduser("~/Pictures/QX-images")
        # Load saved folder from config if available
        if os.path.exists(self.config_path):
            try:
                with open(self.config_path, "r") as f:
                    config = json.load(f)
                    self.saveFolder = config.get("saveFolder", self.defaultSaveFolder)
            except Exception:
                self.saveFolder = self.defaultSaveFolder
        else:
            self.saveFolder = self.defaultSaveFolder
      

        # Camera function buttons
        self.takePicBtn = QPushButton("📷  CAPTURE IMAGE(s)")
        zoomInBtn = QPushButton("🔍 Zoom In (+)")
        zoomOutBtn = QPushButton("🔍 Zoom Out (-)")

        self.connIndicator = QLabel("NO stream")
        self.connIndicator.setAlignment(Qt.AlignCenter)
        self.connIndicator.setStyleSheet("color: blue; font-weight: bold; font-size: 1.2em;")
        self.ISOComboBox = QComboBox(self)

        # livestream view
        self.imgDisplay = ImageDisplay()
        self.imgDisplay.setStyleSheet("border: 1px solid lightgrey;")  # for debugging
        self.imgDisplay.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
        # Overlay toggle
        self.overlayToggleBtn = QPushButton("Toggle view")
        self.overlayToggleBtn.setStyleSheet("background-color: lightgray; color: black; font-weight: bold; font-size: 1.2em;")
        self.overlayToggleBtn.setMinimumHeight(self.ISOComboBox.sizeHint().height())
        self.overlayToggleBtn.clicked.connect(self.imgDisplay.toggleOverlay)
        for btn in [self.takePicBtn, zoomInBtn, zoomOutBtn, self.overlayToggleBtn]:
            btn.setStyleSheet("background-color: lightgray; color: black; font-weight: bold; font-size: 1.5em;")        

        # Restart view button
        self.restartViewBtn = QPushButton("Restart view")
        self.restartViewBtn.setStyleSheet("background-color: lightgray; color: black; font-weight: bold; font-size: 1.2em;")
        self.restartViewBtn.setMinimumHeight(self.ISOComboBox.sizeHint().height())
        self.restartViewBtn.clicked.connect(self.restartLiveview)

        # Message list
        self.messageList = QListWidget(self)
        font = QFont()
        font.setPointSizeF(self.font().pointSizeF() * 1.2)
        self.messageList.setFont(font)
        self.messageList.setStyleSheet("color: lightgray; font-weight: bold;")
        self.messageList.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
        self.messageList.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
        self.messageList.setMinimumHeight(self.messageList.sizeHintForRow(0) * 4 + 8)


        # Layout
        controlLayout = QGridLayout()        
        controlLayout.setSpacing(8)
        controlLayout.setContentsMargins(5, 5, 5, 5)

        controlLayout.addWidget(zoomInBtn, 0, 0)
        controlLayout.addWidget(zoomOutBtn, 0, 1)
        controlLayout.addWidget(self.takePicBtn, 1, 0, 1, 2)

        controlLayout.addWidget(self.overlayToggleBtn, 3, 0, 2, 1)
        controlLayout.addWidget(self.connIndicator, 3, 1)
        controlLayout.addWidget(self.restartViewBtn, 4, 1)
        controlLayout.addWidget(self.messageList, 5, 0, 1, 2)


        # Timed capture controls with labels
        self.startDelayLabel = QLabel("Shooting delay:")
        self.startDelayCombo = QComboBox(self)
        self.startDelayCombo.addItems(["0", "5", "10", "30", "60", "600", "1800"])

        self.intervalLabel = QLabel("Interval:")
        self.intervalCombo = QComboBox(self)
        self.intervalCombo.addItems(["5", "10", "30", "60", "120", "300", "600"])

        self.howManyLabel = QLabel("How many:")
        self.howManyCombo = QComboBox(self)
        self.howManyCombo.addItems(["1", "3", "5", "7", "10", "20", "30", "60", "120", "250", "500"])

        # Apply consistent style
        for combo in [self.startDelayCombo, self.intervalCombo, self.howManyCombo]:
            combo.setStyleSheet("background-color: lightgray; color: black; font-weight: bold; font-size: 1.2em;")
            combo.setMinimumHeight(self.ISOComboBox.sizeHint().height())
        for label in [self.startDelayLabel, self.intervalLabel, self.howManyLabel]:
            label.setAlignment(Qt.AlignRight)
            label.setStyleSheet("color: white; font-weight: bold; font-size: 1.2em;")


        # Main layout
        mainLayout = QGridLayout()
        mainLayout.setSpacing(0)
        mainLayout.setContentsMargins(4, 4, 4, 4) # Reduce outer margins

        # Row 0: All label-dropdown pairs in a single row
        timingLayout = QGridLayout()
        timingLayout.setContentsMargins(0, 0, 5, 5)
        timingLayout.setSpacing(4)

        # Add label-dropdown pairs side by side
        timingLayout.addWidget(self.startDelayLabel, 0, 0)
        timingLayout.addWidget(self.startDelayCombo, 0, 1)
        timingLayout.addWidget(self.intervalLabel, 0, 2)
        timingLayout.addWidget(self.intervalCombo, 0, 3)
        timingLayout.addWidget(self.howManyLabel, 0, 4)
        timingLayout.addWidget(self.howManyCombo, 0, 5)

        # Add ISO label and dropdown (initially styled as background)
        self.isoLabel = QLabel("ISO value:")
        self.isoLabel.setAlignment(Qt.AlignRight)
        self.isoLabel.setStyleSheet("color: white; font-weight: bold; font-size: 1.2em;")
        self.ISOComboBox.setStyleSheet("background-color: #2b2b2b; color: #2b2b2b; font-weight: bold; font-size: 1.2em;")
        self.ISOComboBox.setMinimumHeight(self.startDelayCombo.sizeHint().height())
        timingLayout.addWidget(self.isoLabel, 0, 6)
        timingLayout.addWidget(self.ISOComboBox, 0, 7)

        self.setFolderBtn = QPushButton("Select Folder")
        self.setFolderBtn.setStyleSheet("background-color: lightgray; color: black; font-weight: bold; font-size: 1.2em;")
        self.setFolderBtn.setMinimumHeight(self.ISOComboBox.sizeHint().height())
        self.setFolderBtn.clicked.connect(self.openFolderDialog)
        timingLayout.addWidget(self.setFolderBtn, 0, 8, 1, 2)


        # Add the full row to the main layout, spanning both columns
        mainLayout.addLayout(timingLayout, 0, 0, 1, 2)


        # Add image display, updated v3-3
#        mainLayout.addLayout(self.modeGrid, 0, 0, 1, 2)    # Top row removed
        mainLayout.addWidget(self.imgDisplay, 1, 0)        # Below mode buttons
        mainLayout.addLayout(controlLayout, 1, 1)          # Controls beside image
        
        
        # Stretch factors to balance space
        mainLayout.setColumnStretch(0, 4)  # Image display
        mainLayout.setColumnStretch(1, 1)  # Controls
        mainLayout.setRowStretch(1, 1)  # Image row, updated v3-3

#        self.setLayout(mainLayout)  # replace to try scrollable window to use keyboard safely to layout
        from PyQt5.QtWidgets import QScrollArea
        # Wrap the main layout in a scrollable container
        scroll_area = QScrollArea()
        scroll_area.setWidgetResizable(True)
        scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
        scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
        # Create a container widget to hold the main layout
        container = QWidget()
        container.setLayout(mainLayout)
        container.setContentsMargins(0, 0, 0, 0)  # Ensure no extra padding
        # Set the container as the scroll area's widget
        scroll_area.setWidget(container)
        # Create a new top-level layout and add the scroll area to it
        outer_layout = QVBoxLayout()
        outer_layout.addWidget(scroll_area)
        # Set the outer layout as the main layout of the dialog
        self.setLayout(outer_layout)


        self.resize(1440, 680)  # Librem5 screen size is width: 720 height: 1440, about 5% is used by top and bottom bars when horizontal, so the window is a bit smaller (couldn't find the valueso had to estimate it)
#        self.setFixedSize(1440, 680)  # prevents rotation changing the layout (not really)
        self.setStyleSheet("background-color: #2b2b2b;")

        # Signal connections
        self.takePicBtn.clicked.connect(self.takePic)
        zoomInBtn.pressed.connect(self.zoomIn)
        zoomInBtn.released.connect(self.zoomInStop)
        zoomOutBtn.pressed.connect(self.zoomOut)
        zoomOutBtn.released.connect(self.zoomOutStop)
        self.startDelayCombo.currentTextChanged.connect(self.checkBatteryReminder)
        self.intervalCombo.currentTextChanged.connect(self.checkBatteryReminder)
        self.howManyCombo.currentTextChanged.connect(self.checkBatteryReminder)
        self.howManyCombo.currentTextChanged.connect(self.checkDiskSpaceEstimate)


        #Button height limits
        # Row 0: Zoom buttons
        zoomInBtn.setMinimumHeight(100)
        zoomOutBtn.setMinimumHeight(100)
        zoomInBtn.setMinimumWidth(125)
        zoomOutBtn.setMinimumWidth(125)
        # Row 1: Take Picture button (spans both columns)
        self.takePicBtn.setMinimumHeight(100)
        self.takePicBtn.setMinimumWidth(250)
        # Row 2: ISO controls
        self.isoLabel.setMinimumHeight(20)
        self.ISOComboBox.setMinimumHeight(20)
        # Row 3: Overlay toggle and connection indicator
        self.overlayToggleBtn.setMinimumHeight(self.restartViewBtn.sizeHint().height() * 2 +10)
        self.connIndicator.setMinimumHeight(20)

        self.ISOComboBox.currentTextChanged.connect(self.handleISOChange)
        QTimer.singleShot(3100, self.tryPopulateControls)
        self.monitorConnection()



    def updateConnectionStatus(self, wifi_ok=False, camera_ok=False):
        if wifi_ok and camera_ok:
            self.connIndicator.setText("Stream OK")
        else:
            self.connIndicator.setText("NO stream")


    def monitorConnection(self):
        self.was_connected = False  # Track previous connection state
        self.inSequence = False

        def check():
            if self.inSequence and int(self.intervalCombo.currentText()) < 10:
                return  # Skip pinging during short intervals

            try:
                subprocess.run(
                    ["ping", "-c", "1", CAMERA_IP],
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL,
                    check=True
                )
                self.updateConnectionStatus(wifi_ok=True, camera_ok=True)

            except subprocess.CalledProcessError:
                self.updateConnectionStatus(wifi_ok=True, camera_ok=False)
                if self.was_connected:
                    self.logMessage("Camera connection lost.", is_error=True)
                    self.was_connected = False
                    if self.inSequence:
                        self.takingSequence = False
                        self.takePicBtn.setText("📷  CAPTURE IMAGE(s)")

        self.connection_timer = QTimer()
        self.connection_timer.timeout.connect(check)
        self.connection_timer.start(6217)  # Check every 6.217 seconds to separate it from other activity


# add also re-try of ISOand WB repopulation, v10
    def restartLiveview(self):
        global liveview_thread, running, image, last_action_time
        self.logMessage("Restarting liveview...")
    
        # Stop current liveview thread
        running = False
        if liveview_thread and liveview_thread.is_alive():
            liveview_thread.join(timeout=2)
    
        # Clear image and reset state
        with lock:
            image = QImage()
        self.imgDisplay.update()
        self.updateConnectionStatus(wifi_ok=True, camera_ok=False)
    
        # Restart communication
        running = True
    
        def restart():    
            try:
                communicationThread()
            except Exception as e:
                self.logMessage(f"Liveview restart failed: {e}", is_error=True)
                self.updateConnectionStatus(wifi_ok=True, camera_ok=False)
    
            # Populate ISO and WB if not already populated
            try:
                if self.ISOComboBox.count() == 0:
                    self.logMessage("Re-populating ISO values...")    
                    self.populateExposureControls()
            except Exception as e:
                self.logMessage(f"ISO re-population failed: {e}", is_error=True)
    
        threading.Thread(target=restart, daemon=True).start()


    def resizeEvent(self, event):
        super().resizeEvent(event)
        for scroll_area in self.findChildren(QScrollArea):
            scroll_area.setWidgetResizable(False)
            scroll_area.setWidgetResizable(True)
            scroll_area.widget().adjustSize()
            scroll_area.viewport().updateGeometry()
            scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
            scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)


    def tryPopulateControls(self):
        try:
            if self.ISOComboBox.count() == 0:
                self.logMessage("Populating ISO values...")
                self.populateExposureControls()
        except Exception as e:
            self.logMessage(f"ISO init failed: {e}", is_error=True)


    def openFolderDialog(self):
        from PyQt5.QtWidgets import QButtonGroup, QMessageBox
        from PyQt5.QtCore import QStandardPaths
        import re

        dialog = QDialog(self)
        dialog.setWindowTitle("Select Save Folder")
        dialog.setGeometry(self.imgDisplay.geometry())
        layout = QVBoxLayout()
        layout.setAlignment(Qt.AlignTop)
        layout.setContentsMargins(5, 5, 5, 5)  # or (0, 0, 0, 0)
        layout.setSpacing(5)

        # Load config or fallback
        fallback_folder = QStandardPaths.writableLocation(QStandardPaths.PicturesLocation)
        config = {}
        try:
            if os.path.exists(self.config_path):
                with open(self.config_path, "r") as f:
                    config = json.load(f)
        except Exception:
            self.logMessage("Failed to read config. Using fallback folder.", is_error=True)

        user_defined_default = config.get("saveFolder", self.defaultSaveFolder)
        auto_subfolder_enabled = config.get("autoSubfolder", False)

        # Radio group
        radio_group = QButtonGroup(dialog)

        # Default folder option
        default_radio = QRadioButton("Use as default folder for images (it will be created if it doesn't exist):")
                
        default_radio.setStyleSheet("""
            QRadioButton::indicator {
                width: 20px;
                height: 20px;
                border-radius: 10px;
                border: 4px solid lightgray;
                background-color: white;
            }
            QRadioButton::indicator:checked {
                background-color: blue;
            }
            QRadioButton {
                font-size: 2.5em;
                color: white;
                border: 2px solid lightgray; 
                border-radius: 10px; 
                padding: 10px 10px;

            }
        """)

        default_radio.setChecked(True)
        radio_group.addButton(default_radio)
        default_path_input = QLineEdit(user_defined_default)
        default_path_input.setStyleSheet("font-size: 2.5em; color: white; background-color: #444; margin-left: 20px;")
        default_path_input.setMinimumHeight(40)

        # Auto-subfolder checkbox
        add_subfolder = QCheckBox("Create automatically subfolders under default, based on the year and month (YYYY-MM) when needed")
        add_subfolder.setStyleSheet("font-size: 2.0em; color: lightgray; margin-left: 50px; border: 2px solid lightgray; border-radius: 10px; padding: 10px 16px;")
        add_subfolder.setMinimumHeight(30)
        add_subfolder.setChecked(auto_subfolder_enabled)

        # Session folder option
        session_radio = QRadioButton("Use this alternate folder for this session (it will be created if it doesn't exist):")
        
        session_radio.setStyleSheet("""
            QRadioButton::indicator {
                width: 20px;
                height: 20px;
                border-radius: 10px;
                border: 4px solid lightgray;
                background-color: white;
            }
            QRadioButton::indicator:checked {
                background-color: blue;
            }
            QRadioButton {
                font-size: 2.5em;
                color: white;
                border: 2px solid lightgray; 
                border-radius: 10px; 
                padding: 10px 10px;;
            }
        """)
        
        radio_group.addButton(session_radio)
        session_suggestion = os.path.join(user_defined_default, time.strftime("%Y-%m-%d"))
        session_path = QLineEdit(session_suggestion)
        session_path.setStyleSheet("font-size: 2.5em; color: white; background-color: #444; margin-left: 20px;")
        session_path.setMinimumHeight(40)

        # Layouts
        layout.addWidget(default_radio)
        layout.addWidget(default_path_input)
        layout.addWidget(add_subfolder)
        layout.addSpacing(30)
        layout.addWidget(session_radio)
        layout.addWidget(session_path)

        # Buttons
        buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
        buttons.setStyleSheet("QPushButton { background-color: lightgray; color: black; font-weight: bold; font-size: 2.0em; min-height: 50px; min-width: 200px; }")
        layout.addWidget(buttons)

# check and prevend recursive nesting of dated folders
        def is_recursive_folder(path):
            parts = os.path.normpath(path).split(os.sep)
            date_pattern = re.compile(r"\d{4}-\d{2}(-\d{2})?$")
            # Only check the parent folders, not the last one
            for part in parts[:-1]:
                if date_pattern.fullmatch(part):
                    return True
            return False


        def on_accept():
            base = user_defined_default
            try:
                if default_radio.isChecked():
                    base = default_path_input.text().strip()
                    if not base:
                        raise ValueError("Default folder path cannot be empty.")
                    if add_subfolder.isChecked():
                        if is_recursive_folder(base):
                            raise ValueError("Recursive folder name error")
                        base = os.path.join(base, time.strftime("%Y-%m"))
                    self.saveFolder = base
                    os.makedirs(self.saveFolder, exist_ok=True)
                    if not os.access(self.saveFolder, os.W_OK):
                        raise PermissionError("Selected folder is not writable.")
                    # Save config
                    with open(self.config_path, "w") as f:
                        json.dump({
                            "saveFolder": default_path_input.text().strip(),
                            "autoSubfolder": add_subfolder.isChecked()
                        }, f)
                elif session_radio.isChecked():
                    session = session_path.text().strip()
                    if not session:
                        raise ValueError("Session folder path cannot be empty.")
                    if is_recursive_folder(session):
                        raise ValueError("Recursive folder name error")
                    self.saveFolder = session
                    os.makedirs(self.saveFolder, exist_ok=True)
                    if not os.access(self.saveFolder, os.W_OK):
                        raise PermissionError("Selected folder is not writable.")
                self.logMessage(f"Saving to: {self.saveFolder}")
                dialog.accept()
            except Exception as e:
                self.logMessage(f"{e}\nUsing fallback folder: {fallback_folder}", is_error=True)
                self.saveFolder = fallback_folder
                dialog.reject()

        buttons.accepted.connect(on_accept)
        buttons.rejected.connect(dialog.reject)
        dialog.setLayout(layout)
        
                # Show available disk space in selected folder
        try:
            check_path = default_path_input.text().strip()
            if default_radio.isChecked() and add_subfolder.isChecked():
                check_path = os.path.join(check_path, time.strftime("%Y-%m"))
            elif session_radio.isChecked():
                check_path = session_path.text().strip()

            if os.path.exists(check_path):
                _, _, free = shutil.disk_usage(check_path)
                free_mb = free // (1024 * 1024)
                color = "orange" if free_mb < 200 else "lightgray"
                item = QListWidgetItem(f"{free_mb}MB empty space in save destination.")
                item.setForeground(QColor(color))
                self.messageList.insertItem(0, item)
        except Exception as e:
            self.logMessage(f"Disk space check failed: {e}", is_error=True)

        dialog.exec_()



    def checkBatteryReminder(self):
        try:
            delay = int(self.startDelayCombo.currentText())
            interval = int(self.intervalCombo.currentText())
            count = int(self.howManyCombo.currentText())

            if delay > 60 or interval > 60 or count >= 60:
                self.logMessage("Make sure there is enough battery")
        except ValueError:
            pass  # Ignore invalid selections


    def checkDiskSpaceEstimate(self):
        try:
            count = int(self.howManyCombo.currentText())
            estimated_size_per_image = 5 * 1024 * 1024  # 5MB in bytes
            estimated_total_size = count * estimated_size_per_image

            pictures_dir = self.saveFolder
            total, used, free = shutil.disk_usage(pictures_dir)

            remaining_after_sequence = free - estimated_total_size
            estimated_size_mb = estimated_total_size // (1024 * 1024)

            if count >= 20:
                self.logMessage(f"The {count} images will take about {estimated_size_mb}MB of disc space.")

            if remaining_after_sequence < 100 * 1024 * 1024:
                self.logMessage(f"Disc space may run out before {count} images.", is_error=True)

        except Exception as e:
            self.logMessage(f"Disk space check failed: {e}", is_error=True)


    # write images to Pictures and give warnings if needed in GUI, moved inside class, v3-5, re-made to rename after saving v7
    def downloadImage(self, url):
        
        global last_action_time
        last_action_time = time.time()

        host, port, address, img_name = parseUrl(url)
        conn2 = http.client.HTTPConnection(host, port)
        conn2.request("GET", address)
        response = conn2.getresponse()
        if response.status != 200:
            msg = f"ERROR: Couldn't download picture, error = [{response.status} {response.reason}]"
            print(msg)
            self.logMessage(msg)
            return
        
        # Save original file first
        pictures_dir = self.saveFolder
        original_path = os.path.join(pictures_dir, img_name)
        try:
            os.makedirs(pictures_dir, exist_ok=True)
            if not os.access(pictures_dir, os.W_OK):
                raise PermissionError("Pictures folder is not writable.")
            total, used, free = shutil.disk_usage(pictures_dir)
            if free < 100 * 1024 * 1024:
                raise OSError("Less than 100MB free space.")
            with open(original_path, "wb") as img:
                img.write(response.read())
        except Exception as e:
            msg = f"ERROR: Failed to save image: {e}"
            print(msg)
            self.logMessage(msg)
            return

        # Generate and change to new filename
        timestamp = time.strftime("%y%m%d_%H%M%S")
        ext = os.path.splitext(img_name)[1].lower().lstrip('.')
        ext_map = {"jpeg": "jpg", "jpg": "jpg", "arw": "raw", "raw": "raw", "mp4": "mp4", "mpeg4": "mp4"}
        ext = ext_map.get(ext, ext[:3])
        # Track last timestamp to reset counter, last number changes only if timestamp would be same
        if not hasattr(self, 'last_pic_timestamp') or self.last_pic_timestamp != timestamp:
            self.last_pic_timestamp = timestamp
            self.pic_counter = 0
        else:
            self.pic_counter += 1

        new_name = f"pic{timestamp}_{self.pic_counter}.{ext}"
        new_path = os.path.join(pictures_dir, new_name)

        try:
            os.rename(original_path, new_path)
            print(f"{img_name} saved as {new_name}")
            self.logMessage(f"Saved as: {new_name}")
        except Exception as e:
            msg = f"ERROR: Failed to rename image: {e}"
            print(msg)
            self.logMessage(msg)

            new_name = f"pic{timestamp}_{self.pic_counter}.{ext}"
            file_path = os.path.join(pictures_dir, new_name)

    # make GUI messages better, v3-5
    def logMessage(self, text, is_error=False):
        def update():
            item = QListWidgetItem(text)
            if is_error:
                item.setForeground(QColor("red"))
            else:
                item.setForeground(QColor("D3D3D3"))
            self.messageList.insertItem(0, item)  # Insert at top
            if self.messageList.count() > 10:
                self.messageList.takeItem(10)  # Remove oldest (bottom)
        # QMetaObject.invokeMethod(self, update, Qt.QueuedConnection)
        QTimer.singleShot(0, update)

# redundant maybe?
    def getSupportedExposureModes(self):
        conn = http.client.HTTPConnection(CAMERA_IP, CAMERA_PORT, timeout=5)
        resp = postRequest(conn, "camera", {"method": "getAvailableExposureMode", "params": [], "version": "1.0"})
        if not resp or "result" not in resp or len(resp["result"]) < 2:
            self.logMessage("Failed to get exposure modes", is_error=True)
            return
        self.logMessage("Current Mode:" + resp["result"][0])
        available_modes = resp["result"][1]

# more debug things added, v8
    def populateExposureControls(self):
        try:
            # Set to Manual mode temporarily to access ISO settings
            conn = http.client.HTTPConnection(CAMERA_IP, CAMERA_PORT, timeout=5)
            postRequest(conn, "camera", {
                "method": "setExposureMode",
                "params": ["M"],
                "version": "1.0"
            })
    
            # Fetch available ISO values
            resp = postRequest(conn, "camera", {
                "method": "getAvailableIsoSpeedRate",
                "params": [],
                "version": "1.0"
            }, retries=1)
            print("ISO response:", resp)
    
            if isinstance(resp.get("result"), list) and len(resp["result"]) > 1:
                values = resp["result"][1]
                self.ISOComboBox.clear()
                self.ISOComboBox.addItems(values)
                self.ISOComboBox.setStyleSheet("background-color: lightgrey; color: black; font-weight: bold; font-size: 1.2em;")
                print("Starting ISO population...")
            else:
                self.logMessage("Failed to retrieve ISO values", is_error=True)
    
            # Restore to Program mode
            postRequest(conn, "camera", {
                "method": "setExposureMode",
                "params": ["P"],
               "version": "1.0"
            })

        except Exception as e:
            self.logMessage(f"Error retrieving exposure settings: {e}", is_error=True)


    # added warnings, v3-5
    def takePic(self):
        global last_action_time
        last_action_time = time.time()
    
        if hasattr(self, 'takingSequence') and self.takingSequence:
            self.takingSequence = False
            self.takePicBtn.setText("📷  CAPTURE IMAGE(s)")
            self.logMessage("Sequence stopped by user.")
            return

        try:
            delay = int(self.startDelayCombo.currentText())
            interval = int(self.intervalCombo.currentText())
            count = int(self.howManyCombo.currentText())
        except ValueError:
            self.logMessage("Invalid timing values", is_error=True)
            return

        # Estimate sequence end time and show message if long
        if delay >= 30 or interval >= 30 or count >= 10:
            total_duration = delay + interval * (count - 1)
            end_time = time.localtime(time.time() + total_duration)
            formatted_time = time.strftime("%H:%M:%S", end_time)
            self.logMessage(f"Sequence ready at about {formatted_time}")


        self.takingSequence = True
        self.inSequence = True
        if count > 1 or delay > 0:
            self.takePicBtn.setText("End delayed/multi imaging")
        else:
            self.takePicBtn.setText("📷  CAPTURE IMAGE(s)")


        def sequence():
            nonlocal delay, interval, count
            try:
                if delay > 0:
                    self.logMessage(f"Waiting {delay}s before starting...")
                    time.sleep(delay)
                for i in range(count):
                    if not self.takingSequence:
                        break

                    # Check disk space
                    pictures_dir = self.saveFolder
                    try:
                        total, used, free = shutil.disk_usage(pictures_dir)
                        if free < 100 * 1024 * 1024:
                            self.logMessage("Less than 100MB free space. Stopping sequence.", is_error=True)
                            break
                    except Exception as e:
                        self.logMessage(f"Disk check failed: {e}", is_error=True)
                        break

                    # Check camera connection
                    try:
                        subprocess.run(["ping", "-c", "1", CAMERA_IP],
                                       stdout=subprocess.DEVNULL,
                                       stderr=subprocess.DEVNULL,
                                       check=True)
                    except subprocess.CalledProcessError:
                        self.logMessage("Camera connection lost. Stopping sequence.", is_error=True)
                        break

                    self.logMessage(f"Capturing image {i+1}/{count}")
                    try:
                        conn = http.client.HTTPConnection(CAMERA_IP, CAMERA_PORT, timeout=5)
                        if self.ISOComboBox.currentText() != "AUTO":
                            postRequest(conn, "camera", {
                                "method": "setIsoSpeedRate",
                                "params": [self.ISOComboBox.currentText()],
                                "version": "1.0"
                            })

                        resp = postRequest(conn, "camera", {
                            "method": "actTakePicture",
                            "params": [],
                            "version": "1.0"
                        })
                        if "result" in resp and resp["result"]:
                            threading.Thread(target=self.downloadImage, args=(resp["result"][0][0],), daemon=True).start()
                        else:
                            self.logMessage("Failed to capture image", is_error=True)
                    except Exception as e:
                        self.logMessage(f"Error during capture: {e}", is_error=True)
                        break
        
                    if i < count - 1:
                        time.sleep(interval)
            finally:
                self.takingSequence = False
                self.inSequence = False
                QTimer.singleShot(0, lambda: self.takePicBtn.setText("📷  CAPTURE IMAGE(s)"))

        threading.Thread(target=sequence, daemon=True).start()


    def zoomIn(self):
            
        global last_action_time
        last_action_time = time.time()
        
        conn = http.client.HTTPConnection(CAMERA_IP, CAMERA_PORT, timeout=5)
        resp = postRequest(conn, "camera", {"method": "actZoom", "params": ["in", "start"], "version": "1.0"})

    def zoomInStop(self):
        conn = http.client.HTTPConnection(CAMERA_IP, CAMERA_PORT, timeout=5)
        postRequest(conn, "camera", {"method": "actZoom", "params": ["in", "stop"], "version": "1.0"})
        feedback = postRequest(conn, "camera", {"method": "getEvent", "params": [False], "id": 4, "version": "1.0"})
        zoom_pos = self.extractZoomPosition(feedback)
        if zoom_pos is not None:
            self.logMessage(f"Zoom Position: {zoom_pos}")
        else:
            self.logMessage("Zoom position not available", is_error=True)

    def zoomOut(self):
            
        global last_action_time
        last_action_time = time.time()
        
#        self.logMessage("Zoom Out")
        conn = http.client.HTTPConnection(CAMERA_IP, CAMERA_PORT, timeout=5)
        resp = postRequest(conn, "camera", {"method": "actZoom", "params": ["out", "start"], "version": "1.0"})

    def zoomOutStop(self):
        conn = http.client.HTTPConnection(CAMERA_IP, CAMERA_PORT, timeout=5)
        postRequest(conn, "camera", {"method": "actZoom", "params": ["out", "stop"], "version": "1.0"})
        feedback = postRequest(conn, "camera", {"method": "getEvent", "params": [False], "id": 4, "version": "1.0"})
        zoom_pos = self.extractZoomPosition(feedback)
        if zoom_pos is not None:
            self.logMessage(f"Zoom Position: {zoom_pos}")
        else:
            self.logMessage("Zoom position not available", is_error=True)

    def extractZoomPosition(self, feedback):
        try:
            for item in feedback.get("result", []):
                if isinstance(item, dict) and "zoomPosition" in item:
                    return item["zoomPosition"]
        except Exception as e:
            print("Error extracting zoom position:", e)
        return None


    def handleISOChange(self, text):
        threading.Thread(target=self._setISO, args=(text,), daemon=True).start()

    def _setISO(self, text):
        print('Setting ISO to:', text)
        conn = http.client.HTTPConnection(CAMERA_IP, CAMERA_PORT, timeout=5)
        postRequest(conn, "camera", {"method": "setIsoSpeedRate", "params": [text], "version": "1.0"})


    def clearCombo(self, combo):
        combo.clear()


    def closeEvent(self, event):
        global running, liveview_thread
        print("Closing application...")
        running = False

        try:
            if liveview_thread and liveview_thread.is_alive():
                print("Waiting for liveview thread to finish...")
                liveview_thread.join(timeout=2)

            if hasattr(self, "connection_timer"):
                self.connection_timer.stop()

        except Exception as e:
            print("Error during shutdown:", e)

        finally:
            QApplication.quit()
            event.accept()



if __name__ == "__main__":
    app = QApplication(sys.argv)
    form = Form()  
    form.show()
    communication = threading.Thread(target=communicationThread, daemon=True)
    communication.start()

    try:
        sys.exit(app.exec())
    except Exception as e:
        # Log to console
        print("Unhandled exception in GUI loop:", e)
        # Log to GUI if possible
        if hasattr(form, "logMessage"):
            form.logMessage(f"Unhandled exception: {e}", is_error=True)
    finally:
        running = False
        communication.join()
        if liveview_thread and liveview_thread.is_alive():
            liveview_thread.join()
        app.quit()
4 Likes

Some example pics, taken with QX10 on Librem 5. There seems to be some occasional drops in connection as the liveview from the camera cuts and needs to be manually reconnected (restart and toggle buttons) - probably too busy transferring captured images to files, but tells that the script isn’t perfect. Not a big deal when targets are stationary. First two a re max/min zoom, others are just about dynamics (and I wanted to take some pretty pics). [forum seems to drop the resolution, so unable to show originals in full]

Sidenote: 132pics, 868Mb, L5 battery use about 45% in an hour (wifi streaming).
Pics: CC-BY (4.0)

6 Likes

I love this so much. I really need to get one of these cameras now!

3 Likes

Although they are almost like made for L5 accessories, a word of caution, though: Great quality (especially compared to L5 internal cams) but they are 10-15 year old tech so adjust your expectations and shooting style accordingly. And the GUI script is still a bit WIP.

After playing with it for a while, a few thoughts on pros and cons:

  • it brings a big improvement to any linux phone camera - big sensor, long optical zoom, decent aperture, less optical distortion
  • it’s a bulk (although looks like it was made for L5), it’s extra weight to bring with than just having a phone camera, so you plan to use it
  • it takes good pics without AI enhancement involvement (which is needed in modern phone cameras)… but maybe it can be used in post processing? (it’s just jpg, but it’s still good filesize)
  • it’s slow(-ish) to set up, so you (need to) plan to use it as there are extra steps (attach, power on and connect wifi)
  • the camera itself is simple and straight forward, minimalist even
  • it’s also slow to take repeat photos due to the wifi data transfer, so the feature does “cost” and shooting needs be a bit more deliberate (what was “normal” around 2010, but now might be considered slow and sparing), about 5 second interval
  • as a separate camera, you can use it a few meters away from phone: mount it on a stick or some weird angle which gives some new room to play with, to get some special photos (and I’m hoping at some point it might work as a V4L streaming device for video meetings etc.)
  • although it looks like it, it’s not an action cam and stabilization isn’t as good as today’s action cams but then again, it’s not supposed to be
  • separate battery (which are still available and newer are a bit better)
  • L5 still uses it’s own battery for the wifi connection
  • it does have on camera physical buttons for zoom and shooting, as well as internal memory SD card slot to save pics
  • there is a slight lag with the liveview due to wifi (same with Sony’s official app)
  • the optics are great and it takes a very expensive phone to get anything near the quality, while this is available quite cheaply (used)
  • several features are not available (or may take a lot of work), so not at par with the official app
  • the linux GUI script gives possibilities to set some automation and settings more to own liking (like, how and where to save, sequences etc.)

So, not for everyone - depends on your usecase / style of photographing. I expect not to take all my photos with this but often also with internal cam.