// 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 ("OPC")) { 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 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); }