Initial commit

This commit is contained in:
Alicia Moore
2026-06-22 22:05:39 -07:00
commit 9d35b244c2
5 changed files with 1106 additions and 0 deletions
+412
View File
@@ -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);
}