Initial commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
olympuswifi
|
||||
src/*.c
|
||||
@@ -0,0 +1,18 @@
|
||||
CC = valac
|
||||
PKGS = --pkg gtk4 --pkg libadwaita-1 --pkg libsoup-2.4 --pkg gio-2.0 --pkg gdk-pixbuf-2.0 --pkg pango
|
||||
SRCS = src/main.vala src/camera-client.vala
|
||||
TARGET = olympuswifi
|
||||
CFLAGS = -g
|
||||
|
||||
all: $(TARGET)
|
||||
|
||||
$(TARGET): $(SRCS)
|
||||
$(CC) $(CFLAGS) $(PKGS) $(SRCS) -o $(TARGET)
|
||||
|
||||
run: $(TARGET)
|
||||
./$(TARGET)
|
||||
|
||||
clean:
|
||||
rm -f $(TARGET)
|
||||
|
||||
.PHONY: all run clean
|
||||
Binary file not shown.
@@ -0,0 +1,412 @@
|
||||
// Camera client for Olympus OPC protocol
|
||||
// Communicates with camera at 192.168.0.10:80 via HTTP
|
||||
|
||||
public class ImageInfo : Object {
|
||||
public string directory { get; set; }
|
||||
public string filename { get; set; }
|
||||
public int64 size { get; set; }
|
||||
public int attribute { get; set; }
|
||||
public DateTime? date_time { get; set; }
|
||||
public string full_path { get; set; }
|
||||
|
||||
public bool is_hidden {
|
||||
get { return (attribute & 2) != 0; }
|
||||
}
|
||||
|
||||
public bool is_directory {
|
||||
get { return (attribute & 16) != 0; }
|
||||
}
|
||||
|
||||
public string display_date {
|
||||
owned get {
|
||||
if (date_time == null) return "Unknown";
|
||||
return date_time.format ("%Y-%m-%d %H:%M");
|
||||
}
|
||||
}
|
||||
|
||||
public ImageInfo (string directory, string filename, int64 size, int attribute,
|
||||
DateTime? date_time, string full_path) {
|
||||
this.directory = directory;
|
||||
this.filename = filename;
|
||||
this.size = size;
|
||||
this.attribute = attribute;
|
||||
this.date_time = date_time;
|
||||
this.full_path = full_path;
|
||||
}
|
||||
}
|
||||
|
||||
public class CameraClient : Object {
|
||||
private Soup.Session session;
|
||||
private MainContext? context = null;
|
||||
private bool owns_context = false;
|
||||
private const string CAMERA_HOST = "192.168.0.10";
|
||||
private const int CAMERA_PORT = 80;
|
||||
private const int TIMEOUT_SECONDS = 5;
|
||||
|
||||
public bool connected { get; private set; default = false; }
|
||||
public static bool debug { get; set; default = false; }
|
||||
|
||||
public CameraClient () {
|
||||
// Worker threads typically have no thread-default GMainContext.
|
||||
// Soup.Session.send_message internally iterates a GMainContext,
|
||||
// so we must push one to avoid crashes with NULL context.
|
||||
context = MainContext.get_thread_default ();
|
||||
if (context == null) {
|
||||
context = new MainContext ();
|
||||
context.push_thread_default ();
|
||||
owns_context = true;
|
||||
}
|
||||
session = new Soup.Session ();
|
||||
session.user_agent = "OI.Share v2";
|
||||
session.timeout = TIMEOUT_SECONDS;
|
||||
}
|
||||
|
||||
~CameraClient () {
|
||||
if (owns_context && context != null) {
|
||||
context.pop_thread_default ();
|
||||
}
|
||||
}
|
||||
|
||||
private string build_url (string path) {
|
||||
return "http://%s:%d%s".printf( CAMERA_HOST, CAMERA_PORT, path);
|
||||
}
|
||||
|
||||
// Perform synchronous HTTP GET, return response body as Bytes
|
||||
private Bytes do_get (string url) throws Error {
|
||||
if (debug) {
|
||||
stdout.printf (">>> GET %s\n", url);
|
||||
}
|
||||
|
||||
var msg = new Soup.Message ("GET", url);
|
||||
session.send_message (msg);
|
||||
|
||||
if (debug) {
|
||||
stdout.printf ("<<< HTTP %u %s\n", msg.status_code, msg.reason_phrase);
|
||||
}
|
||||
|
||||
if (msg.status_code != 200) {
|
||||
if (debug) {
|
||||
stdout.printf ("<<< ERROR: HTTP %u - body follows:\n", msg.status_code);
|
||||
unowned uint8[] err_raw = msg.response_body.data;
|
||||
if (err_raw != null) {
|
||||
var err_body = (string) err_raw;
|
||||
if (((string) err_body).validate ())
|
||||
stdout.printf ("%s\n", (string) err_body);
|
||||
}
|
||||
}
|
||||
throw new IOError.FAILED (
|
||||
"HTTP request to %s failed with status %u", url, msg.status_code
|
||||
);
|
||||
}
|
||||
|
||||
// Extract response body as Bytes.
|
||||
// Use unowned access to avoid an extra intermediate copy.
|
||||
unowned uint8[] raw_data = msg.response_body.data;
|
||||
var bytes = new Bytes (raw_data);
|
||||
|
||||
if (debug) {
|
||||
if (raw_data != null) {
|
||||
var body_str = (string) raw_data;
|
||||
if (((string) body_str).validate ()) {
|
||||
stdout.printf ("<<< Body (%ld bytes):\n%s\n", raw_data.length, (string) body_str);
|
||||
} else {
|
||||
stdout.printf ("<<< Body: %ld bytes (binary data)\n", raw_data.length);
|
||||
}
|
||||
} else {
|
||||
stdout.printf ("<<< Body: 0 bytes (empty)\n");
|
||||
}
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// --- Public API ---
|
||||
|
||||
// Probe camera by fetching its command list.
|
||||
// Modern Olympus cameras don't necessarily return OPC mode from
|
||||
// get_connectmode.cgi; instead they respond to get_commandlist.cgi
|
||||
// directly. This matches the approach used by the working
|
||||
// olympus-wifi library (OI.Share v2 user-agent).
|
||||
public bool check_connection( ) throws Error {
|
||||
if (debug) {
|
||||
stdout.printf ("[debug] Probing camera with get_commandlist.cgi…\n");
|
||||
}
|
||||
try {
|
||||
var body = do_get (build_url ("/get_commandlist.cgi"));
|
||||
string xml = (string)body.get_data ();
|
||||
if (xml.contains ("<?xml") && xml.contains( "<cgi")) {
|
||||
connected = true;
|
||||
if (debug) {
|
||||
stdout.printf ("[debug] Camera responded with valid command list, connected.\n");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (debug) {
|
||||
stdout.printf ("[debug] Unexpected response from get_commandlist:\n%s\n", xml);
|
||||
}
|
||||
} catch (Error e) {
|
||||
if (debug) {
|
||||
stdout.printf ("[debug] get_commandlist failed: %s\n", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try get_connectmode.cgi (older cameras)
|
||||
if (debug) {
|
||||
stdout.printf ("[debug] Falling back to get_connectmode.cgi…\n");
|
||||
}
|
||||
try {
|
||||
var body = do_get (build_url ("/get_connectmode.cgi"));
|
||||
string xml = (string)body.get_data ();
|
||||
if (xml.contains ("<connectmode>OPC</connectmode>")) {
|
||||
connected = true;
|
||||
return true;
|
||||
}
|
||||
if (debug) {
|
||||
stdout.printf ("[debug] get_connectmode returned: %s\n", xml.replace( "\n", " ").strip( ));
|
||||
}
|
||||
} catch (Error e) {
|
||||
if (debug) {
|
||||
stdout.printf ("[debug] get_connectmode failed: %s\n", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Switch operation mode (play, rec)
|
||||
// Note: modern Olympus cameras use "switch_cammode.cgi" (one 'm')
|
||||
// not "switch_cameramode.cgi" as in the older OPC spec.
|
||||
public bool switch_mode( string mode) throws Error {
|
||||
string url = build_url ("/switch_cammode.cgi?mode=%s".printf( mode));
|
||||
var body = do_get (url);
|
||||
string xml = (string)body.get_data ();
|
||||
if (debug) {
|
||||
stdout.printf ("[debug] switch_cammode response: %s\n", xml.replace( "\n", " ").strip( ));
|
||||
}
|
||||
// Some cameras return <result>OK, some just return HTTP 200 with empty body
|
||||
return true;
|
||||
}
|
||||
|
||||
// Activate session (some cameras require this before other commands)
|
||||
public void activate( ) throws Error {
|
||||
try {
|
||||
do_get (build_url ("/get_activate.cgi"));
|
||||
if (debug) {
|
||||
stdout.printf ("[debug] get_activate succeeded\n");
|
||||
}
|
||||
} catch (Error e) {
|
||||
if (debug) {
|
||||
stdout.printf ("[debug] get_activate failed (non-fatal): %s\n", e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get list of items in a DCF directory
|
||||
// Returns an array of ImageInfo
|
||||
// Note: 404 means empty directory on modern cameras, returns empty list.
|
||||
public ImageInfo[] get_image_list( string dir) throws Error {
|
||||
string url = build_url ("/get_imglist.cgi?DIR=%s".printf( dir));
|
||||
Bytes body;
|
||||
try {
|
||||
body = do_get (url);
|
||||
} catch (Error e) {
|
||||
if (e.message.contains ("status 404")) {
|
||||
if (debug) {
|
||||
stdout.printf ("[debug] get_imglist(%s): 404 (empty directory)\n", dir);
|
||||
}
|
||||
return new ImageInfo[0];
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
string text = (string)body.get_data ();
|
||||
|
||||
string[] lines = text.split ("\n");
|
||||
|
||||
if (debug) {
|
||||
stdout.printf ("[debug] get_imglist(%s) response (%lu bytes):\n", dir, body.get_size( ));
|
||||
// Print first few lines for debugging
|
||||
for (int i = 0; i < lines.length && i < 5; i++) {
|
||||
stdout.printf ("[debug] line %d: %s\n", i, lines[i]);
|
||||
}
|
||||
}
|
||||
var results = new ImageInfo[lines.length - 1]; // max possible
|
||||
int count = 0;
|
||||
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
string line = lines[i].strip ();
|
||||
if (line.length == 0 || line == "VER_100") continue;
|
||||
|
||||
string[] parts = line.split (",", 6);
|
||||
if (parts.length < 6) continue;
|
||||
|
||||
string directory = parts[0];
|
||||
string filename = parts[1];
|
||||
int64 size = int64.parse (parts[2]);
|
||||
int attribute = int.parse (parts[3]);
|
||||
string full_path = "%s/%s".printf( directory, filename);
|
||||
|
||||
// Parse packed date
|
||||
int64 packed_date = int64.parse( parts[4]);
|
||||
int64 packed_time = int64.parse (parts[5]);
|
||||
|
||||
DateTime? dt = decode_opc_date (packed_date, packed_time);
|
||||
|
||||
results[count] = new ImageInfo (directory, filename, size, attribute, dt, full_path);
|
||||
count++;
|
||||
}
|
||||
|
||||
// Trim to actual count
|
||||
var trimmed = new ImageInfo[count];
|
||||
for (int i = 0; i < count; i++) {
|
||||
trimmed[i] = results[i];
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Discover all DCF directories on the camera.
|
||||
// Probes multiple paths and tries common DCF directory names.
|
||||
// Returns an array of directory paths that contain images.
|
||||
public string[] discover_dcf_directories( ) throws Error {
|
||||
string[] found_dirs = new string[0];
|
||||
|
||||
// Probe known DCF paths that could contain subdirectories
|
||||
string[] probe_paths = {"/", "/DCIM/"};
|
||||
|
||||
int count = 0;
|
||||
foreach (string probe in probe_paths) {
|
||||
try {
|
||||
var items = get_image_list (probe);
|
||||
// Make a first pass to count subdirectories
|
||||
int sub_count = 0;
|
||||
foreach (var item in items) {
|
||||
if (item.is_directory && !item.is_hidden) {
|
||||
sub_count++;
|
||||
}
|
||||
}
|
||||
if (sub_count > 0) {
|
||||
found_dirs = new string[sub_count];
|
||||
int idx = 0;
|
||||
foreach (var item in items) {
|
||||
if (item.is_directory && !item.is_hidden) {
|
||||
found_dirs[idx] = item.full_path;
|
||||
idx++;
|
||||
if (debug) {
|
||||
stdout.printf ("[debug] Discovered DCF dir: %s\n", item.full_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
count = sub_count;
|
||||
break;
|
||||
}
|
||||
} catch (Error e) {
|
||||
if (debug) {
|
||||
stdout.printf ("[debug] Probe %s: %s\n", probe, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If probe paths yielded directories, return them
|
||||
if (count > 0) {
|
||||
return found_dirs;
|
||||
}
|
||||
|
||||
// Fallback: try standard DCF directory names directly
|
||||
string[] fallbacks = {"/DCIM/100OLYMP", "/DCIM/101OLYMP", "/DCIM/102OLYMP",
|
||||
"/DCIM/103OLYMP", "/DCIM/104OLYMP", "/DCIM/105OLYMP"};
|
||||
int fb_count = 0;
|
||||
foreach (string d in fallbacks) {
|
||||
try {
|
||||
get_image_list (d);
|
||||
fb_count++;
|
||||
} catch (Error e) {
|
||||
// doesn't exist, skip
|
||||
}
|
||||
}
|
||||
if (fb_count > 0) {
|
||||
found_dirs = new string[fb_count];
|
||||
int idx = 0;
|
||||
foreach (string d in fallbacks) {
|
||||
try {
|
||||
get_image_list (d);
|
||||
found_dirs[idx] = d;
|
||||
idx++;
|
||||
if (debug) {
|
||||
stdout.printf ("[debug] Found DCF directory (fallback): %s\n", d);
|
||||
}
|
||||
} catch (Error e) {
|
||||
}
|
||||
}
|
||||
return found_dirs;
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
stdout.printf ("[debug] No DCF directories found - camera may have no SD card\n");
|
||||
}
|
||||
|
||||
return found_dirs;
|
||||
}
|
||||
|
||||
// Get all images across all DCF directories
|
||||
public ImageInfo[] get_all_images( ) throws Error {
|
||||
var dirs = discover_dcf_directories ();
|
||||
|
||||
// First pass: count total images
|
||||
int total = 0;
|
||||
foreach (string dir in dirs) {
|
||||
var images = get_image_list (dir);
|
||||
foreach (var img in images) {
|
||||
if (!img.is_directory && !img.is_hidden) {
|
||||
total++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var all = new ImageInfo[total];
|
||||
int idx = 0;
|
||||
foreach (string dir in dirs) {
|
||||
var images = get_image_list (dir);
|
||||
foreach (var img in images) {
|
||||
if (!img.is_directory && !img.is_hidden) {
|
||||
all[idx] = img;
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return all;
|
||||
}
|
||||
|
||||
// Get thumbnail image (160x120 JPEG)
|
||||
public Bytes get_thumbnail( string path) throws Error {
|
||||
string url = build_url ("/get_thumbnail.cgi?DIR=%s".printf( path));
|
||||
return do_get (url);
|
||||
}
|
||||
|
||||
// Get image for control device display (1920x1440 max)
|
||||
public Bytes get_screennail( string path) throws Error {
|
||||
string url = build_url ("/get_screennail.cgi?DIR=%s".printf( path));
|
||||
return do_get (url);
|
||||
}
|
||||
|
||||
// Download original image file from camera
|
||||
public Bytes download_original( string path) throws Error {
|
||||
return do_get (build_url (path));
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to decode packed date integer from OPC protocol
|
||||
// bits 4-0: day, bits 8-5: month, bits 15-9: year (offset from 1980)
|
||||
DateTime? decode_opc_date( int64 packed_date, int64 packed_time) {
|
||||
int day = (int)(packed_date & 0x1F);
|
||||
int month = (int)((packed_date >> 5) & 0x0F);
|
||||
int year = (int)((packed_date >> 9) + 1980);
|
||||
int seconds = (int)(packed_time & 0x1F) * 2;
|
||||
int minutes = (int)((packed_time >> 5) & 0x3F);
|
||||
int hours = (int)((packed_time >> 11) & 0x1F);
|
||||
|
||||
if (year < 1980 || month < 1 || month > 12 || day < 1 || day > 31) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new DateTime.local (year, month, day, hours, minutes, seconds);
|
||||
}
|
||||
+674
@@ -0,0 +1,674 @@
|
||||
// Olympus Wi-Fi Camera Viewer
|
||||
// GTK4 + Libadwaita application for browsing and importing images
|
||||
// from Olympus cameras using the OPC protocol
|
||||
|
||||
public class ImageItem : Gtk.FlowBoxChild {
|
||||
private Gtk.Image thumbnail_image;
|
||||
private Gtk.Label name_label;
|
||||
private Gtk.Label date_label;
|
||||
private Gtk.Box main_box;
|
||||
private Gtk.Overlay overlay;
|
||||
private Gtk.CheckButton check_button;
|
||||
|
||||
public ImageInfo info { get; construct; }
|
||||
public bool selected {
|
||||
get { return check_button.active; }
|
||||
set { check_button.active = value; }
|
||||
}
|
||||
|
||||
public signal void selection_changed ();
|
||||
|
||||
public ImageItem (ImageInfo info) {
|
||||
Object (info: info);
|
||||
}
|
||||
|
||||
construct {
|
||||
hexpand = true;
|
||||
halign = Gtk.Align.CENTER;
|
||||
|
||||
overlay = new Gtk.Overlay ();
|
||||
|
||||
main_box = new Gtk.Box (Gtk.Orientation.VERTICAL, 4);
|
||||
main_box.margin_start = 4;
|
||||
main_box.margin_end = 4;
|
||||
main_box.margin_top = 4;
|
||||
main_box.margin_bottom = 4;
|
||||
|
||||
// Thumbnail image
|
||||
thumbnail_image = new Gtk.Image( );
|
||||
thumbnail_image.pixel_size = 160;
|
||||
thumbnail_image.icon_name = "image-x-generic";
|
||||
thumbnail_image.halign = Gtk.Align.CENTER;
|
||||
thumbnail_image.valign = Gtk.Align.CENTER;
|
||||
|
||||
var image_frame = new Gtk.Frame (null);
|
||||
image_frame.child = thumbnail_image;
|
||||
image_frame.width_request = 160;
|
||||
image_frame.height_request = 120;
|
||||
main_box.append (image_frame);
|
||||
|
||||
// File name
|
||||
name_label = new Gtk.Label( info.filename);
|
||||
name_label.ellipsize = Pango.EllipsizeMode.END;
|
||||
name_label.max_width_chars = 20;
|
||||
name_label.halign = Gtk.Align.CENTER;
|
||||
var attr_list = new Pango.AttrList ();
|
||||
attr_list.insert (Pango.attr_weight_new (Pango.Weight.BOLD));
|
||||
name_label.attributes = attr_list;
|
||||
main_box.append (name_label);
|
||||
|
||||
// Date
|
||||
date_label = new Gtk.Label( info.display_date);
|
||||
date_label.ellipsize = Pango.EllipsizeMode.END;
|
||||
date_label.max_width_chars = 20;
|
||||
date_label.halign = Gtk.Align.CENTER;
|
||||
date_label.opacity = 0.7;
|
||||
main_box.append (date_label);
|
||||
|
||||
overlay.child = main_box;
|
||||
|
||||
// Check button overlay for selection (top-left corner)
|
||||
check_button = new Gtk.CheckButton( );
|
||||
check_button.halign = Gtk.Align.START;
|
||||
check_button.valign = Gtk.Align.START;
|
||||
check_button.margin_start = 8;
|
||||
check_button.margin_top = 8;
|
||||
check_button.add_css_class ("selection-mode");
|
||||
overlay.add_overlay (check_button);
|
||||
|
||||
child = overlay;
|
||||
|
||||
check_button.toggled.connect (() => {
|
||||
update_selection_style ();
|
||||
selection_changed ();
|
||||
});
|
||||
|
||||
// Click toggles selection
|
||||
var gesture = new Gtk.GestureClick( );
|
||||
gesture.pressed.connect ((n_press, x, y) => {
|
||||
if (n_press == 1) {
|
||||
check_button.active = !check_button.active;
|
||||
}
|
||||
});
|
||||
add_controller (gesture);
|
||||
|
||||
add_css_class ("image-item");
|
||||
}
|
||||
|
||||
private void update_selection_style () {
|
||||
if (check_button.active) {
|
||||
add_css_class ("selected-image");
|
||||
} else {
|
||||
remove_css_class ("selected-image");
|
||||
}
|
||||
}
|
||||
|
||||
public void set_thumbnail_from_bytes (Bytes bytes) {
|
||||
try {
|
||||
var texture = Gdk.Texture.from_bytes (bytes);
|
||||
if (texture != null) {
|
||||
thumbnail_image.set_from_paintable (texture);
|
||||
} else {
|
||||
thumbnail_image.icon_name = "image-x-generic";
|
||||
}
|
||||
} catch (Error e) {
|
||||
warning ("Failed to load thumbnail: %s", e.message);
|
||||
thumbnail_image.icon_name = "image-x-generic";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class ConnectPage : Gtk.Box {
|
||||
private Gtk.Button connect_button;
|
||||
private Gtk.Label status_label;
|
||||
private Gtk.Spinner spinner;
|
||||
private Gtk.Box status_box;
|
||||
|
||||
public signal void connection_succeeded ();
|
||||
|
||||
public ConnectPage () {
|
||||
Object (
|
||||
orientation: Gtk.Orientation.VERTICAL,
|
||||
spacing: 16,
|
||||
halign: Gtk.Align.CENTER,
|
||||
valign: Gtk.Align.CENTER
|
||||
);
|
||||
}
|
||||
|
||||
construct {
|
||||
var title_label = new Gtk.Label ("Olympus Wi-Fi Camera");
|
||||
var title_attrs = new Pango.AttrList ();
|
||||
title_attrs.insert (Pango.attr_scale_new (Pango.Scale.XX_LARGE));
|
||||
title_attrs.insert (Pango.attr_weight_new (Pango.Weight.BOLD));
|
||||
title_label.attributes = title_attrs;
|
||||
title_label.add_css_class ("title-label");
|
||||
append (title_label);
|
||||
|
||||
var subtitle_label = new Gtk.Label ("Browse and import photos from your Olympus camera");
|
||||
subtitle_label.opacity = 0.7;
|
||||
append (subtitle_label);
|
||||
|
||||
append (new Gtk.Label (""));
|
||||
|
||||
// Connect button
|
||||
connect_button = new Gtk.Button.with_label( "Connect to Camera");
|
||||
connect_button.add_css_class ("suggested-action");
|
||||
connect_button.add_css_class ("circular");
|
||||
connect_button.hexpand = false;
|
||||
connect_button.width_request = 250;
|
||||
connect_button.height_request = 60;
|
||||
connect_button.valign = Gtk.Align.CENTER;
|
||||
|
||||
var button_box = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 0);
|
||||
button_box.halign = Gtk.Align.CENTER;
|
||||
button_box.append (connect_button);
|
||||
append (button_box);
|
||||
|
||||
// Status box (spinner + label)
|
||||
status_box = new Gtk.Box( Gtk.Orientation.HORIZONTAL, 8);
|
||||
status_box.halign = Gtk.Align.CENTER;
|
||||
status_box.visible = false;
|
||||
|
||||
spinner = new Gtk.Spinner ();
|
||||
status_box.append (spinner);
|
||||
|
||||
status_label = new Gtk.Label ("");
|
||||
status_box.append (status_label);
|
||||
|
||||
append (status_box);
|
||||
|
||||
connect_button.clicked.connect (on_connect_clicked);
|
||||
}
|
||||
|
||||
private void on_connect_clicked () {
|
||||
set_connecting_state ("Connecting to camera…");
|
||||
connect_button.sensitive = false;
|
||||
new Thread<void*> ("connect", connect_thread);
|
||||
}
|
||||
|
||||
private void* connect_thread () {
|
||||
var camera = new CameraClient ();
|
||||
|
||||
try {
|
||||
// Step 1: Check/establish OPC connection
|
||||
Idle.add( () => {
|
||||
status_label.label = "Establishing OPC connection…";
|
||||
return false;
|
||||
});
|
||||
Thread.usleep (100000);
|
||||
|
||||
if (!camera.check_connection ()) {
|
||||
string msg = "Failed: Camera is not responding with OPC protocol.";
|
||||
Idle.add (() => {
|
||||
set_idle_state (msg);
|
||||
connect_button.sensitive = true;
|
||||
return false;
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Step 2: Activate session (some cameras require this)
|
||||
Idle.add( () => {
|
||||
status_label.label = "Activating session…";
|
||||
return false;
|
||||
});
|
||||
Thread.usleep (100000);
|
||||
camera.activate ();
|
||||
|
||||
// Step 3: Switch to playback mode
|
||||
Idle.add( () => {
|
||||
status_label.label = "Switching to playback mode…";
|
||||
return false;
|
||||
});
|
||||
Thread.usleep (100000);
|
||||
|
||||
if (!camera.switch_mode ("play")) {
|
||||
string msg = "Failed to switch camera to playback mode.";
|
||||
Idle.add (() => {
|
||||
set_idle_state (msg);
|
||||
connect_button.sensitive = true;
|
||||
return false;
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
Idle.add (() => {
|
||||
set_idle_state ("Connected! Loading gallery…");
|
||||
connect_button.sensitive = true;
|
||||
connection_succeeded ();
|
||||
return false;
|
||||
});
|
||||
|
||||
} catch (Error connect_err) {
|
||||
string msg = "Connection failed: %s".printf( connect_err.message);
|
||||
Idle.add (() => {
|
||||
set_idle_state (msg);
|
||||
connect_button.sensitive = true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void set_connecting_state (string message) {
|
||||
status_box.visible = true;
|
||||
spinner.start ();
|
||||
status_label.label = message;
|
||||
status_label.remove_css_class ("error");
|
||||
status_label.remove_css_class ("success");
|
||||
}
|
||||
|
||||
private void set_idle_state (string message) {
|
||||
spinner.stop ();
|
||||
status_label.label = message;
|
||||
if (message.has_prefix ("Failed") || message.has_prefix( "Connection failed")) {
|
||||
status_label.add_css_class ("error");
|
||||
status_label.remove_css_class ("success");
|
||||
} else if (message.has_prefix ("Connected")) {
|
||||
status_label.add_css_class ("success");
|
||||
status_label.remove_css_class ("error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class GalleryPage : Gtk.Box {
|
||||
private Gtk.FlowBox flowbox;
|
||||
private Gtk.Label status_label;
|
||||
private Gtk.Button import_button;
|
||||
private Gtk.Button disconnect_button;
|
||||
private Gtk.ProgressBar progress_bar;
|
||||
private Gtk.Box progress_box;
|
||||
private Gtk.Label progress_label;
|
||||
|
||||
private ImageInfo[] all_images;
|
||||
private ImageItem[] image_widgets;
|
||||
|
||||
public signal void disconnect_requested ();
|
||||
|
||||
public GalleryPage () {
|
||||
Object (
|
||||
orientation: Gtk.Orientation.VERTICAL,
|
||||
spacing: 0
|
||||
);
|
||||
this.all_images = new ImageInfo[0];
|
||||
this.image_widgets = new ImageItem[0];
|
||||
}
|
||||
|
||||
construct {
|
||||
// Toolbar
|
||||
var toolbar_box = new Gtk.Box( Gtk.Orientation.HORIZONTAL, 8);
|
||||
toolbar_box.margin_start = 12;
|
||||
toolbar_box.margin_end = 12;
|
||||
toolbar_box.margin_top = 8;
|
||||
toolbar_box.margin_bottom = 8;
|
||||
|
||||
disconnect_button = new Gtk.Button.with_label ("Disconnect");
|
||||
disconnect_button.add_css_class ("flat");
|
||||
toolbar_box.append (disconnect_button);
|
||||
|
||||
var spacer = new Gtk.Label ("");
|
||||
spacer.hexpand = true;
|
||||
toolbar_box.append (spacer);
|
||||
|
||||
status_label = new Gtk.Label ("Loading images…");
|
||||
status_label.halign = Gtk.Align.CENTER;
|
||||
status_label.hexpand = true;
|
||||
toolbar_box.append (status_label);
|
||||
|
||||
var spacer2 = new Gtk.Label ("");
|
||||
spacer2.hexpand = true;
|
||||
toolbar_box.append (spacer2);
|
||||
|
||||
import_button = new Gtk.Button.with_label ("Import Selected");
|
||||
import_button.add_css_class ("suggested-action");
|
||||
import_button.sensitive = false;
|
||||
toolbar_box.append (import_button);
|
||||
|
||||
append (toolbar_box);
|
||||
append (new Gtk.Separator (Gtk.Orientation.HORIZONTAL));
|
||||
|
||||
// Scrolled window with FlowBox
|
||||
var scrolled = new Gtk.ScrolledWindow( );
|
||||
scrolled.vexpand = true;
|
||||
scrolled.hexpand = true;
|
||||
|
||||
flowbox = new Gtk.FlowBox ();
|
||||
flowbox.max_children_per_line = 6;
|
||||
flowbox.selection_mode = Gtk.SelectionMode.NONE;
|
||||
flowbox.homogeneous = true;
|
||||
flowbox.column_spacing = 8;
|
||||
flowbox.row_spacing = 8;
|
||||
flowbox.margin_start = 12;
|
||||
flowbox.margin_end = 12;
|
||||
flowbox.margin_top = 8;
|
||||
flowbox.margin_bottom = 8;
|
||||
flowbox.activate_on_single_click = false;
|
||||
|
||||
scrolled.child = flowbox;
|
||||
append (scrolled);
|
||||
|
||||
// Progress box
|
||||
progress_box = new Gtk.Box( Gtk.Orientation.VERTICAL, 4);
|
||||
progress_box.margin_start = 12;
|
||||
progress_box.margin_end = 12;
|
||||
progress_box.margin_top = 8;
|
||||
progress_box.margin_bottom = 8;
|
||||
progress_box.visible = false;
|
||||
|
||||
progress_label = new Gtk.Label ("");
|
||||
progress_box.append (progress_label);
|
||||
|
||||
progress_bar = new Gtk.ProgressBar ();
|
||||
progress_bar.show_text = false;
|
||||
progress_box.append (progress_bar);
|
||||
|
||||
append (progress_box);
|
||||
|
||||
disconnect_button.clicked.connect (() => { disconnect_requested (); });
|
||||
import_button.clicked.connect (on_import_clicked);
|
||||
|
||||
load_images ();
|
||||
}
|
||||
|
||||
private void load_images () {
|
||||
status_label.label = "Loading images…";
|
||||
import_button.sensitive = false;
|
||||
new Thread<void*> ("load", load_images_thread);
|
||||
}
|
||||
|
||||
private void* load_images_thread () {
|
||||
// Create dedicated CameraClient in this thread so Soup.Session
|
||||
// runs on a GMainContext owned by this thread (avoids segfault).
|
||||
var load_camera = new CameraClient ();
|
||||
try {
|
||||
var images = load_camera.get_all_images ();
|
||||
Idle.add (() => {
|
||||
all_images = images;
|
||||
populate_gallery ();
|
||||
status_label.label = "%d images found".printf( all_images.length);
|
||||
update_import_button_state ();
|
||||
return false;
|
||||
});
|
||||
} catch (Error err) {
|
||||
string msg = "Error: %s".printf( err.message);
|
||||
Idle.add (() => {
|
||||
status_label.label = msg;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void populate_gallery () {
|
||||
var child = flowbox.get_first_child ();
|
||||
while (child != null) {
|
||||
var next = child.get_next_sibling ();
|
||||
flowbox.remove (child);
|
||||
child = next;
|
||||
}
|
||||
image_widgets = new ImageItem[all_images.length];
|
||||
|
||||
for (int i = 0; i < all_images.length; i++) {
|
||||
var info = all_images[i];
|
||||
var item = new ImageItem (info);
|
||||
item.selection_changed.connect (update_import_button_state);
|
||||
flowbox.append (item);
|
||||
image_widgets[i] = item;
|
||||
load_thumbnail_async (item, info.full_path);
|
||||
}
|
||||
}
|
||||
|
||||
private void load_thumbnail_async (ImageItem item, string path) {
|
||||
new Thread<void*> ("thumb", () => {
|
||||
// Create a separate CameraClient per thread for thread safety
|
||||
var thumb_camera = new CameraClient( );
|
||||
try {
|
||||
var bytes = thumb_camera.get_thumbnail (path);
|
||||
Idle.add (() => {
|
||||
item.set_thumbnail_from_bytes (bytes);
|
||||
return false;
|
||||
});
|
||||
} catch (Error e) {
|
||||
warning ("Thumbnail failed for %s: %s", path, e.message);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
private void update_import_button_state () {
|
||||
int count = 0;
|
||||
for (int i = 0; i < image_widgets.length; i++) {
|
||||
if (image_widgets[i].selected) count++;
|
||||
}
|
||||
import_button.sensitive = count > 0;
|
||||
import_button.label = count > 0
|
||||
? "Import Selected (%d)".printf( count)
|
||||
: "Import Selected";
|
||||
}
|
||||
|
||||
private void on_import_clicked () {
|
||||
int count = 0;
|
||||
for (int i = 0; i < image_widgets.length; i++) {
|
||||
if (image_widgets[i].selected) count++;
|
||||
}
|
||||
if (count == 0) return;
|
||||
|
||||
var selected = new ImageInfo[count];
|
||||
int idx = 0;
|
||||
for (int i = 0; i < image_widgets.length; i++) {
|
||||
if (image_widgets[i].selected) {
|
||||
selected[idx] = image_widgets[i].info;
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
|
||||
var file_dialog = new Gtk.FileDialog ();
|
||||
file_dialog.title = "Select Destination Directory";
|
||||
file_dialog.select_folder.begin ((Gtk.Window?)this.get_root (), null, (obj, res) => {
|
||||
try {
|
||||
var dest = file_dialog.select_folder.end (res);
|
||||
if (dest != null) {
|
||||
start_import (selected, dest);
|
||||
}
|
||||
} catch (Error e) {
|
||||
warning ("Folder selection failed: %s", e.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void start_import (ImageInfo[] files, GLib.File dest_dir) {
|
||||
import_button.sensitive = false;
|
||||
disconnect_button.sensitive = false;
|
||||
progress_box.visible = true;
|
||||
progress_bar.fraction = 0.0;
|
||||
|
||||
int total = files.length;
|
||||
progress_label.label = "Importing 0 of %d…".printf( total);
|
||||
|
||||
// Use a dedicated thread with its own CameraClient instance.
|
||||
// Each thread gets a fresh Soup.Session so there's no sharing.
|
||||
new Thread<void*>( "import", () => {
|
||||
int completed = 0;
|
||||
int failed = 0;
|
||||
var import_camera = new CameraClient ();
|
||||
|
||||
for (int f = 0; f < total; f++) {
|
||||
var file_info = files[f];
|
||||
try {
|
||||
var data = import_camera.download_original (file_info.full_path);
|
||||
var dest_path = dest_dir.get_child (file_info.filename);
|
||||
dest_path.replace_contents (
|
||||
data.get_data (), null, false,
|
||||
GLib.FileCreateFlags.NONE, null, null
|
||||
);
|
||||
completed++;
|
||||
} catch (Error e) {
|
||||
failed++;
|
||||
warning ("Failed to import %s: %s", file_info.filename, e.message);
|
||||
}
|
||||
|
||||
// Snapshot progress locals so the idle callback does not
|
||||
// race with the worker thread updating completed/failed.
|
||||
int snap_completed = completed;
|
||||
int snap_failed = failed;
|
||||
var snap_info = file_info;
|
||||
Idle.add (() => {
|
||||
progress_bar.fraction = (double)(snap_completed + snap_failed) / total;
|
||||
progress_label.label = "Importing %d of %d (%s)".printf(
|
||||
snap_completed, total, snap_info.filename);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
int final_completed = completed;
|
||||
int final_failed = failed;
|
||||
Idle.add (() => {
|
||||
if (final_failed == 0) {
|
||||
progress_label.label = "Successfully imported %d of %d images!".printf( final_completed, total);
|
||||
progress_label.add_css_class ("success");
|
||||
} else {
|
||||
progress_label.label = "Imported %d of %d (%d failed)".printf( final_completed, total, final_failed);
|
||||
progress_label.add_css_class ("error");
|
||||
}
|
||||
progress_bar.fraction = 1.0;
|
||||
|
||||
Timeout.add (3000, () => {
|
||||
progress_box.visible = false;
|
||||
progress_bar.fraction = 0.0;
|
||||
progress_label.remove_css_class ("success");
|
||||
progress_label.remove_css_class ("error");
|
||||
import_button.sensitive = true;
|
||||
disconnect_button.sensitive = true;
|
||||
for (int i = 0; i < image_widgets.length; i++) {
|
||||
image_widgets[i].selected = false;
|
||||
}
|
||||
update_import_button_state ();
|
||||
return false;
|
||||
});
|
||||
return false;
|
||||
});
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
public void refresh () {
|
||||
load_images ();
|
||||
}
|
||||
}
|
||||
|
||||
public class OlympusApp : Adw.Application {
|
||||
public OlympusApp () {
|
||||
Object (
|
||||
application_id: "com.olympuswifi.viewer",
|
||||
flags: ApplicationFlags.DEFAULT_FLAGS
|
||||
);
|
||||
}
|
||||
|
||||
protected override void activate () {
|
||||
load_css ();
|
||||
var window = new MainWindow (this);
|
||||
window.present ();
|
||||
}
|
||||
|
||||
private void load_css () {
|
||||
var display = Gdk.Display.get_default ();
|
||||
if (display == null) return;
|
||||
var css_provider = new Gtk.CssProvider ();
|
||||
css_provider.load_from_string (CSS);
|
||||
Gtk.StyleContext.add_provider_for_display (
|
||||
display,
|
||||
css_provider,
|
||||
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
|
||||
);
|
||||
}
|
||||
|
||||
private const string CSS = """
|
||||
.title-label { font-weight: bold; }
|
||||
.selected-image {
|
||||
background-color: alpha(@accent_bg_color, 0.2);
|
||||
border: 2px solid @accent_bg_color;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.image-item {
|
||||
border-radius: 8px;
|
||||
border: 2px solid transparent;
|
||||
transition: all 200ms ease;
|
||||
}
|
||||
.image-item:hover {
|
||||
background-color: alpha(@accent_bg_color, 0.08);
|
||||
}
|
||||
.error { color: @error_bg_color; font-weight: bold; }
|
||||
.success { color: @success_bg_color; font-weight: bold; }
|
||||
button.circular {
|
||||
border-radius: 30px;
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
public class MainWindow : Adw.ApplicationWindow {
|
||||
private Gtk.Stack stack;
|
||||
private ConnectPage connect_page;
|
||||
private GalleryPage? gallery_page;
|
||||
|
||||
public MainWindow (Adw.Application app) {
|
||||
Object (
|
||||
application: app,
|
||||
title: "Olympus Wi-Fi Camera Viewer",
|
||||
default_width: 900,
|
||||
default_height: 700
|
||||
);
|
||||
}
|
||||
|
||||
construct {
|
||||
stack = new Gtk.Stack ();
|
||||
stack.transition_type = Gtk.StackTransitionType.SLIDE_LEFT_RIGHT;
|
||||
|
||||
connect_page = new ConnectPage ();
|
||||
connect_page.connection_succeeded.connect (on_connected);
|
||||
stack.add_named (connect_page, "connect");
|
||||
|
||||
set_content (stack);
|
||||
stack.visible_child = connect_page;
|
||||
}
|
||||
|
||||
private void on_connected () {
|
||||
if (gallery_page == null) {
|
||||
gallery_page = new GalleryPage ();
|
||||
gallery_page.disconnect_requested.connect (on_disconnect);
|
||||
stack.add_named (gallery_page, "gallery");
|
||||
} else {
|
||||
gallery_page.refresh ();
|
||||
}
|
||||
stack.visible_child = gallery_page;
|
||||
}
|
||||
|
||||
private void on_disconnect () {
|
||||
stack.visible_child = connect_page;
|
||||
}
|
||||
}
|
||||
|
||||
public static int main (string[] args) {
|
||||
// Parse and strip debug flag before GTK processes args
|
||||
var filtered = new string[args.length];
|
||||
int out = 0;
|
||||
for (int i = 0; i < args.length; i++) {
|
||||
if (args[i] == "-d" || args[i] == "--debug") {
|
||||
CameraClient.debug = true;
|
||||
stdout.printf ("[debug] Enabled verbose HTTP logging\n");
|
||||
} else {
|
||||
filtered[out] = args[i];
|
||||
out++;
|
||||
}
|
||||
}
|
||||
// Trim to actual count
|
||||
var clean_args = new string[out];
|
||||
for (int i = 0; i < out; i++) {
|
||||
clean_args[i] = filtered[i];
|
||||
}
|
||||
|
||||
var app = new OlympusApp ();
|
||||
return app.run (clean_args);
|
||||
}
|
||||
Reference in New Issue
Block a user