Étiquette : Objective-C

  • Ressusciter un iPhone 4S en 2025 : datalogger GPS + IMU 10 Hz (de l’idée à l’IPA)





    Ressusciter un iPhone 4S en 2025 : datalogger GPS + IMU 10 Hz (de l’idée à l’IPA)


    Ressusciter un iPhone 4S en 2025 : datalogger GPS + IMU 10 Hz (de l’idée à l’IPA)

    Oui, on peut encore donner une deuxième vie à un iPhone 4S (iOS 9.3.x, ARMv7) : un datalogger GPS + IMU qui enregistre à 10 Hz et exporte en CSV. Ce billet condense toutes les étapes, le code, et les pièges contournés.

    TL;DR

    • Compiler avec Xcode 13.2.1 sur macOS Monterey pour cibler armv7 et iOS 9.0.
    • App Objective-C (UIKit + CoreLocation + CoreMotion), sans dépendances Swift.
    • Échantillonnage 10 Hz via dispatch_source_t ; export CSV + UIFileSharingEnabled.
    • Installer sur 4S via Export Development/Ad Hoc ou IPA non signée + Sideloadly.
    • Bonus : commandes UTM/QEMU pour brancher l’iPhone en USB à une VM Windows/iTunes.

    1) Contexte & contrainte

    Les toolchains modernes ne supportent plus armv7/iOS 9. Xcode 13.2.1 est la dernière version confortable pour compiler et signer des apps 32 bits. On reste en Objective‑C pur.

    2) Préparer l’environnement

    Option A — macOS Monterey natif (recommandé)

    1. Télécharge Xcode 13.2.1 (More Downloads). Place Xcode.app dans /Applications.
    2. Sélectionne la version :
    sudo xcode-select -s /Applications/Xcode.app
    sudo xcodebuild -license accept

    Option B — VM UTM macOS 12 (si ton Mac hôte est trop récent)

    1. UTM → New → Virtualize → macOS.
    2. Fournis l’IPSW Monterey (dernière : UniversalMac_12.6.1_21G217_Restore.ipsw).
    3. 4 CPU / 8 Go RAM / 80 Go disque. Installe Xcode 13.2.1 dans la VM.

    3) Créer le projet (iOS 9, armv7)

    • App iOS Objective‑C, Single View App.
    • Deployment Target : 9.0
    • Architectures : armv7 (ou “Standard (armv7)” dans Xcode 13)
    • Enable Bitcode : NO
    • Signing automatique ; Bundle ID unique.

    Info.plist — clés utiles

    • NSLocationWhenInUseUsageDescription
    • NSLocationAlwaysUsageDescription (ou NSLocationAlwaysAndWhenInUseUsageDescription selon la version)
    • NSMotionUsageDescription
    • UIBackgroundModeslocation (si logging écran éteint)
    • UIFileSharingEnabledtrue (récupérer les CSV via Finder/iTunes)

    4) Code de l’app : UI minimaliste & CSV 10 Hz

    Deux boutons (Start/Stop, Share CSV) et un label de statut. Location en BestForNavigation, accéléro/gyro via Core Motion, timer 10 Hz.

    ViewController.m (complet)

    // ViewController.m
    #import "ViewController.h"
    #import <UIKit/UIKit.h>
    #import <CoreLocation/CoreLocation.h>
    #import <CoreMotion/CoreMotion.h>
    #import <TargetConditionals.h>
    #import <math.h>
    
    @interface ViewController () <CLLocationManagerDelegate>
    @property (nonatomic, strong) CLLocationManager *loc;
    @property (nonatomic, strong) CMMotionManager *mm;
    #if __has_feature(objc_arc)
    @property (atomic, strong) CLLocation *lastLoc;
    #else
    @property (atomic, retain) CLLocation *lastLoc;
    #endif
    @property (atomic) CMAcceleration  lastAccel;
    @property (atomic) CMRotationRate  lastGyro;
    #if __has_feature(objc_arc)
    @property (nonatomic, strong) NSFileHandle *fh;
    @property (nonatomic, strong) NSURL *fileURL;
    #else
    @property (nonatomic, retain) NSFileHandle *fh;
    @property (nonatomic, retain) NSURL *fileURL;
    #endif
    #if __has_feature(objc_arc)
    @property (nonatomic, strong) dispatch_source_t timer;
    #else
    @property (nonatomic, assign) dispatch_source_t timer;
    #endif
    @property (nonatomic) UIButton *toggleBtn;
    @property (nonatomic) UIButton *shareBtn;
    @property (nonatomic) UILabel  *statusLabel;
    @property (nonatomic) BOOL isLogging;
    @end
    
    @implementation ViewController
    
    - (void)viewDidLoad {
        [super viewDidLoad];
        self.view.backgroundColor = [UIColor whiteColor];
        [self buildUI];
        [self setupLocation];
        [self setupMotion];
        [self updateUIForState];
    }
    
    - (void)buildUI {
        UIFont *f = [UIFont boldSystemFontOfSize:16.0];
        self.toggleBtn = [UIButton buttonWithType:UIButtonTypeSystem];
        self.toggleBtn.frame = CGRectMake(20, 60, 120, 44);
        self.toggleBtn.titleLabel.font = f;
        [self.toggleBtn setTitle:@"Start" forState:UIControlStateNormal];
        [self.toggleBtn addTarget:self action:@selector(toggleLogging) forControlEvents:UIControlEventTouchUpInside];
        [self.view addSubview:self.toggleBtn];
    
        self.shareBtn = [UIButton buttonWithType:UIButtonTypeSystem];
        self.shareBtn.frame = CGRectMake(180, 60, 120, 44);
        self.shareBtn.titleLabel.font = f;
        [self.shareBtn setTitle:@"Share CSV" forState:UIControlStateNormal];
        [self.shareBtn addTarget:self action:@selector(shareCSV) forControlEvents:UIControlEventTouchUpInside];
        self.shareBtn.enabled = NO;
        [self.view addSubview:self.shareBtn];
    
        self.statusLabel = [[UILabel alloc] initWithFrame:CGRectMake(20, 120, 280, 200)];
        self.statusLabel.font = [UIFont systemFontOfSize:14.0];
        self.statusLabel.textColor = [UIColor darkGrayColor];
        self.statusLabel.numberOfLines = 0;
        self.statusLabel.text = @"Ready.";
        [self.view addSubview:self.statusLabel];
    }
    
    - (void)updateUIForState {
        [self.toggleBtn setTitle:(self.isLogging ? @"Stop" : @"Start") forState:UIControlStateNormal];
        self.shareBtn.enabled = (self.fileURL != nil);
        self.statusLabel.text = self.isLogging ? @"Logging @10Hz…" : @"Stopped.";
    }
    
    #pragma mark - Location & Motion
    - (void)setupLocation {
        self.loc = [CLLocationManager new];
        self.loc.delegate = self;
        self.loc.desiredAccuracy = kCLLocationAccuracyBestForNavigation;
        self.loc.distanceFilter  = kCLDistanceFilterNone;
        if ([self.loc respondsToSelector:@selector(requestWhenInUseAuthorization)]) {
            [self.loc requestWhenInUseAuthorization];
        }
        if ([self.loc respondsToSelector:@selector(requestAlwaysAuthorization)]) {
            [self.loc requestAlwaysAuthorization];
        }
        if ([self.loc respondsToSelector:@selector(setAllowsBackgroundLocationUpdates:)]) {
            self.loc.allowsBackgroundLocationUpdates = YES;
        }
        [self.loc startUpdatingLocation];
        if ([self.loc respondsToSelector:@selector(startUpdatingHeading)]) {
            [self.loc startUpdatingHeading];
        }
    }
    
    - (void)setupMotion {
        self.mm = [CMMotionManager new];
        self.mm.accelerometerUpdateInterval = 1.0/100.0;
        self.mm.gyroUpdateInterval         = 1.0/100.0;
        NSOperationQueue *q = [NSOperationQueue new];
        if (self.mm.isAccelerometerAvailable) {
            [self.mm startAccelerometerUpdatesToQueue:q withHandler:^(CMAccelerometerData *data, NSError *error) {
                if (data) { self.lastAccel = data.acceleration; }
            }];
        }
        if (self.mm.isGyroAvailable) {
            [self.mm startGyroUpdatesToQueue:q withHandler:^(CMGyroData *data, NSError *error) {
                if (data) { self.lastGyro = data.rotationRate; }
            }];
        }
    }
    
    #pragma mark - Logging
    - (void)toggleLogging {
        if (self.isLogging) { [self stopLogging]; }
        else { [self startLogging]; }
        [self updateUIForState];
    }
    
    - (void)startLogging {
        if (self.fh) return;
        [self setupLoggingFile];
        [self startTenHzTimer];
        self.isLogging = YES;
        [self updateUIForState];
    }
    
    - (void)stopLogging {
        if (self.timer) {
            dispatch_source_cancel(self.timer);
    #if !__has_feature(objc_arc)
      #if !OS_OBJECT_USE_OBJC
            dispatch_release(self.timer);
      #endif
    #endif
            self.timer = nil;
        }
        if (self.fh) { [self.fh closeFile]; self.fh = nil; }
        self.isLogging = NO;
        [self updateUIForState];
    }
    
    - (void)setupLoggingFile {
        NSDateFormatter *fmt = [NSDateFormatter new];
        fmt.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"];
        fmt.dateFormat = @"yyyy-MM-dd_HH-mm-ss";
        NSString *fname = [NSString stringWithFormat:@"log_%@.csv", [fmt stringFromDate:[NSDate date]]];
        NSString *docs = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) firstObject];
        NSString *path = [docs stringByAppendingPathComponent:fname];
        self.fileURL = [NSURL fileURLWithPath:path];
        [[NSFileManager defaultManager] createFileAtPath:path contents:nil attributes:nil];
        NSError *err = nil;
        self.fh = [NSFileHandle fileHandleForWritingToURL:self.fileURL error:&err];
        if (!self.fh) {
            self.statusLabel.text = [NSString stringWithFormat:@"File open error: %@", err.localizedDescription ?: @"unknown"];
            return;
        }
        NSString *header = @"ts_ms,lat,lon,alt,acc_h,speed,course,ax,ay,az,gx,gy,gz\n";
        [self.fh writeData:[header dataUsingEncoding:NSUTF8StringEncoding]];
        self.shareBtn.enabled = YES;
    }
    
    - (void)startTenHzTimer {
        dispatch_queue_t q = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
        self.timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, q);
        uint64_t interval = (uint64_t)(0.1 * NSEC_PER_SEC);
        dispatch_source_set_timer(self.timer,
                                  dispatch_time(DISPATCH_TIME_NOW, 0),
                                  interval,
                                  (uint64_t)(0.02 * NSEC_PER_SEC));
    #if __has_feature(objc_arc)
        __weak typeof(self) wself = self;
    #else
        __unsafe_unretained typeof(self) wself = self;
    #endif
        dispatch_source_set_event_handler(self.timer, ^{ [wself tickAndWriteCSV]; });
        dispatch_resume(self.timer);
    }
    
    - (void)tickAndWriteCSV {
        if (!self.fh) return;
        NSTimeInterval t = [[NSDate date] timeIntervalSince1970];
        long long ms = (long long)llround(t * 1000.0);
        CLLocation *L = self.lastLoc;
        double lat=NAN, lon=NAN, alt=NAN, acc=NAN, speed=NAN, course=NAN;
        if (L) { lat=L.coordinate.latitude; lon=L.coordinate.longitude; alt=L.altitude; acc=L.horizontalAccuracy; speed=L.speed; course=L.course; }
        CMAcceleration a = self.lastAccel; CMRotationRate g = self.lastGyro;
        NSString *line = [NSString stringWithFormat:
            @"%lld,%.8f,%.8f,%.2f,%.2f,%.2f,%.2f,%.5f,%.5f,%.5f,%.5f,%.5f,%.5f\n",
            ms, lat, lon, alt, acc, speed, course, a.x, a.y, a.z, g.x, g.y, g.z];
        @synchronized (self) { [self.fh writeData:[line dataUsingEncoding:NSUTF8StringEncoding]]; }
    }
    
    #pragma mark - Partage (simulateur vs appareil)
    - (void)shareCSV {
        if (!self.fileURL) {
            UIAlertController *ac = [UIAlertController alertControllerWithTitle:@"Aucun fichier"
                message:@"Commence par Start pour créer un CSV."
                preferredStyle:UIAlertControllerStyleAlert];
            [ac addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:nil]];
            [self presentViewController:ac animated:YES completion:nil];
            return;
        }
        if (self.timer) { dispatch_source_cancel(self.timer); self.timer=nil; self.isLogging = NO; [self updateUIForState]; }
        if (self.fh) { [self.fh synchronizeFile]; [self.fh closeFile]; self.fh=nil; }
    
    #if TARGET_OS_SIMULATOR
        // Simulateur: partager le TEXTE pour éviter -10814
        NSError *readErr=nil; NSString *contents=[NSString stringWithContentsOfURL:self.fileURL encoding:NSUTF8StringEncoding error:&readErr];
        NSArray *items = contents ? @[contents] : @[self.fileURL.path];
        UIActivityViewController *vc=[[UIActivityViewController alloc] initWithActivityItems:items applicationActivities:nil];
        [self presentViewController:vc animated:YES completion:nil];
    #else
        // Appareil: partager l’URL (copie dans /tmp pour éviter les locks)
        NSString *tmpPath=[NSTemporaryDirectory() stringByAppendingPathComponent:self.fileURL.lastPathComponent];
        [[NSFileManager defaultManager] removeItemAtPath:tmpPath error:NULL];
        NSError *copyErr=nil; if (![[NSFileManager defaultManager] copyItemAtPath:self.fileURL.path toPath:tmpPath error:&copyErr]) {
            UIAlertController *ac=[UIAlertController alertControllerWithTitle:@"Copie impossible" message:copyErr.localizedDescription?:@"Erreur inconnue" preferredStyle:UIAlertControllerStyleAlert];
            [ac addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:nil]];
            [self presentViewController:ac animated:YES completion:nil];
            return;
        }
        NSURL *tmpURL=[NSURL fileURLWithPath:tmpPath];
        UIActivityViewController *vc=[[UIActivityViewController alloc] initWithActivityItems:@[tmpURL] applicationActivities:nil];
        [self presentViewController:vc animated:YES completion:nil];
    #endif
    }
    
    #pragma mark - CLLocationManagerDelegate
    - (void)locationManager:(CLLocationManager *)manager didUpdateLocations:(NSArray<CLLocation *> *)locations {
        CLLocation *last = [locations lastObject];
        if (last) {
    #if __has_feature(objc_arc)
            self.lastLoc = last;
    #else
            if (self.lastLoc != last) { [self.lastLoc release]; self.lastLoc = [last retain]; }
    #endif
            dispatch_async(dispatch_get_main_queue(), ^{
                self.statusLabel.text = [NSString stringWithFormat:@"GPS: %.5f, %.5f",
                                         last.coordinate.latitude, last.coordinate.longitude];
            });
        }
    }
    
    - (void)dealloc {
        if (self.timer) { dispatch_source_cancel(self.timer); self.timer=nil; }
        [self.mm stopAccelerometerUpdates]; [self.mm stopGyroUpdates]; [self.loc stopUpdatingLocation];
        if (self.fh) { [self.fh closeFile]; self.fh = nil; }
    #if !__has_feature(objc_arc)
        [self.loc release]; [self.mm release]; [self.fileURL release]; [self.lastLoc release]; [super dealloc];
    #endif
    }
    @end
    

    CSV : ts_ms,lat,lon,alt,acc_h,speed,course,ax,ay,az,gx,gy,gz


    5) Générer un .IPA armv7

    Depuis Xcode (GUI)

    1. Scheme : Generic iOS Device (ou iPhone 4S).
    2. Product → Archive → Organizer.
    3. Export… : Development (compte gratuit, appareil ajouté) ou Ad Hoc (compte payant + UDID).

    Ligne de commande

    xcodebuild -project GPSIMU10Hz.xcodeproj   -scheme GPSIMU10Hz -configuration Release   -sdk iphoneos -archivePath build/GPSIMU10Hz.xcarchive archive
    
    cat > build/exportOptions.plist <<'PLIST'
    <?xml version="1.0" encoding="UTF-8"?>
    <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
    <plist version="1.0"><dict>
      <key>method</key><string>development</string>
      <key>compileBitcode</key><false/>
      <key>signingStyle</key><string>automatic</string>
      <key>stripSwiftSymbols</key><true/>
      <key>destination</key><string>export</string>
    </dict></plist>
    PLIST
    
    xcodebuild -exportArchive   -archivePath build/GPSIMU10Hz.xcarchive   -exportOptionsPlist build/exportOptions.plist   -exportPath build/export

    Plan B : IPA “crue” + Sideloadly

    # Depuis DerivedData/.../Build/Products/Release-iphoneos/
    mkdir -p Payload
    cp -R GPSIMU10Hz.app Payload/
    zip -r -y GPSIMU10Hz_unsigned.zip Payload
    mv GPSIMU10Hz_unsigned.zip GPSIMU10Hz_unsigned.ipa

    Installe l’IPA via Sideloadly (Windows). Avec Apple ID gratuit, la signature dure 7 jours.


    6) Bonus : brancher l’iPhone 4S à une VM Windows (UTM/QEMU)

    Pour utiliser de vieux iTunes : QEMU x86_64 en TCG (sur Mac ARM), UEFI OVMF, EHCI (USB 2.0), et capture du device Apple 05ac:12a0.

    qemu-system-x86_64   -machine q35,accel=tcg,thread=multi   -cpu qemu64 -smp 4 -m 4096   -drive if=pflash,format=raw,readonly=on,file=/opt/homebrew/share/qemu/edk2-x86_64-code.fd   -drive if=pflash,format=raw,file=$HOME/OVMF_VARS_WIN11.fd   -drive file=/chemin/win11.qcow2,if=virtio,format=qcow2   -vga virtio   -usb -device usb-ehci,id=ehci   -device usb-host,bus=ehci.0,vendorid=0x05ac,productid=0x12a0

    Astuce : ferme Photos/Finder/iTunes sur macOS avant de brancher l’iPhone ; branche après le boot de Windows.


    7) Dépannage express

    • EXC_BAD_ACCESS sur last.coordinate : la propriété lastLoc doit être strong/retain.
    • Simulateur : -10814 au partage : partager NSString (contenu) plutôt que l’URL de fichier.
    • Undefined symbol / link failed : si Quick Look utilisé, lier QuickLook.framework.
    • Syntaxe ObjC : [self updateUIForState] (pas [self.updateUIForState]).
    • QEMU : write lock : disque ouvert par UTM → quitter UTM.
    • QEMU : invalid accelerator hvf : utiliser -accel tcg,thread=multi.
    • UEFI Shell au boot : vérifier -drive (NVMe/SATA) + présence de Windows Boot Manager.

    Conclusion

    Avec Xcode 13.2.1, une app Objective‑C compacte et quelques contournements, on transforme un 4S en datalogger GPS/IMU 10 Hz fiable — prêt pour la route, la piste ou le labo.