Ei SDK:ta, ei BLE-paritustanssia – vain kameran kiinteä IP, Dio-asiakasohjelma ja muutama kompastuskivi, jotka hiljaa pilaavat päiväsi, jos et tiedä niistä.
Miksi WiFi, ei Bluetooth?
Useimmat ”yhdistä GoPro-kameraan” -oppaat alkavat Bluetooth Low Energyn kanssa: skannaa, parita, vaihda GATT-ominaisuuksia, sitten käytä BLE:tä WiFi:n käynnistämiseen ja siirrä tiedot sitten joka tapauksessa WiFi:n kautta. Se toimii, mutta siinä on paljon muodollisuuksia – ja BLE on hidas ja hankala siihen, mitä käyttäjät todella haluavat, eli hallita kameraa ja siirtää kuvamateriaalia nopeasti.
Tässä sovelluksessa ohitamme siis BLE:n kokonaan. Käyttäjä liittyy GoPro-kameran WiFi-tukiasemaan puhelimensa asetuksista, ja siitä eteenpäin kaikki tapahtuu pelkällä HTTP:llä kameran Open GoPro API:a vastaan kiinteässä osoitteessa:
http://10.5.5.9:8080
Kameran ohjaus, asetukset, median listaus ja tiedostojen lataus ovat kaikki HTTP GET -pyyntöjä tuohon IP-osoitteeseen. Koko integrointi on 100 % Dartia dio-paketilla – ei lainkaan natiivia Kotlinia/Swiftiä. Tämä on pääasia, ja kuten lopussa näemme, se on myös ainoa kohta, jossa suunnittelu on hauras.
Kulku:
liity GoPro WiFi AP:hen (puhelimen asetukset)
→ tunnista, että kamera on tavoitettavissa (TCP-tutka osoitteeseen 10.5.5.9:8080)
→ yhdistä + tunnista malli (GET /gopro/camera/state)
→ käynnistä keep-alive (30s) + tilakysely (5s) ajastimet
→ ohjaus: laukaisin / tila / asetukset
→ listaa media (GET /gopro/media/list)
→ lataa turbo-tilassa + edistyminen (dio.download)
1. Kameran tunnistaminen ilman WiFi-skannausoikeuksia
Ensimmäinen vaisto on luetella WiFi SSID:t ja etsiä sellaista, joka alkaa GoProlla. Älä tee niin – se tuo mukanaan sijaintiluvat ja alustakohtaiset WiFi-skannaus-API:t.
GoPro asuu aina samassa IP-osoitteessa, joten tunnistus on yksinkertaisesti: voinko avata TCP-socketin osoitteeseen 10.5.5.9:8080? Ei HTTP:tä, ei oikeuksia, ei SSID-jäsentämistä.
static const String goProIp = '10.5.5.9';
static const int goProPort = 8080;
static const Duration checkInterval = Duration(seconds: 2);
static const Duration connectionTimeout = Duration(seconds: 3);
Future<void> _checkConnection() async {
try {
final socket = await Socket.connect(goProIp, goProPort, timeout: connectionTimeout);
socket.destroy();
if (_currentState != GoProWifiState.connected) {
_currentState = GoProWifiState.connected;
_connectionStateController.add(_currentState);
}
} catch (e) {
if (_currentState != GoProWifiState.disconnected) {
_currentState = GoProWifiState.disconnected;
_connectionStateController.add(_currentState);
}
}
}
Kysymme tilaa 2 sekunnin välein ja lähetämme tilamuutokset virran yli. Yhteysnäyttö tilaa virran ja vaihtaa tilaan "connected" heti, kun socket avautuu. Koska todellista laitetunnistusta ei ole, palvelu syntetisoi virtuaalisen CameraDevicen (kiinteän IP-osoitteen perusteella), jotta lopun kameran abstraktion ei tarvitse tietää eroa.
2. HTTP-asiakasohjelma: Dio ja Open GoPro -päätepisteet
Asiakasohjelma on ohut Dio-käärö. Huomaa lyhyt yhteysaikakatkaisu (olet LAN-verkossa – jos se on hidas, se on rikki) ja runsas vastaanottoaikakatkaisu ohjauspyynnöille:
static const String baseUrl = 'http://10.5.5.9:8080';
GoProHttpClient() {
_dio = Dio(BaseOptions(
baseUrl: baseUrl
connectTimeout: const Duration(seconds: 5)
receiveTimeout: const Duration(seconds: 10)
headers: {'Accept': 'application/json'}
));
}
Open GoPro API on ihastuttavan yhtenäinen: kaikki on HTTP GET -pyyntöjä, jopa komennot ja asetusten muutokset. Tässä ovat ne päätepisteet, joita tämä sovellus todella käyttää:
| Tarkoitus | Metodi + Polku | Kysely |
|---|---|---|
| Käynnistä suljin (tallennus / valokuva) | GET /gopro/camera/shutter/start | — |
| Pysäytä suljin | GET /gopro/camera/shutter/stop | — |
| Keep-alive | GET /gopro/camera/keep_alive | — |
| Koko tila (status + asetukset) | GET /gopro/camera/state | — |
| Aseta esiasetusryhmä | GET /gopro/camera/presets/set_group | id (1000/1001/1002) |
| Muuta asetusta | GET /gopro/camera/setting | setting, option |
| Medialista | GET /gopro/media/list | — |
| Lataa tiedosto | GET /videos/DCIM/{dir}/{file} | (bytes) |
| Pienoiskuva / screennail | GET /gopro/media/thumbnail / .../screennail | path |
| Turbo-siirto | GET /gopro/media/turbo_transfer | p (1/0) |
| Poista .360-ryhmä (vanha) | GET /gp/gpControl/command/storage/delete/group | p |
Mukava yksityiskohta: sekä moderni /gopro/...-perhe että vanha /gp/gpControl/...-perhe elävät rinnakkain. Chaptered.360/GS-ryhmätiedostojen poistaminen, jota uusi API ei pysty käsittelemään siististi, on ainoa tilanne, jossa turvaudumme vanhaan päätepisteeseen.
403 itsekorjausmalli (siistein temppu tässä)
Et voi tietää etukäteen, mitkä asetukset ovat kelvollisia kameran nykyisessä tilassa – resoluutio, joka on sallittu 16:9-kuvasuhteessa, on laiton 9:16-kuvasuhteessa, FPS-vaihtoehdot riippuvat resoluutiosta ja niin edelleen. GoPro kertoo tämän hankalasti: se palauttaa HTTP 403:n luettelolla vaihtoehdoista, jotka olisivat olleet kelvollisia, ja HTTP 500:n, kun se on vain hetkellisesti varattu.
Jäsenämme siis 403-vastauksen tyypilliseksi virheeksi ja yritämme 500-virheen uudelleen:
Future<void> setSetting(int settingId, int optionValue) async {
const maxRetries = 3;
for (int attempt = 1; attempt <= maxRetries; attempt++) {
try {
await _dio.get('/gopro/camera/setting',
queryParameters: {'setting': settingId, 'option': optionValue});
return;
} catch (e) {
if (e is DioException && e.type == DioExceptionType.badResponse) {
final statusCode = e.response?.statusCode;
if (statusCode == 403) {
final body = e.response?.data;
List<int> availableIds = [];
if (body is Map<String, dynamic>) {
final options = body['available_options'];
if (options is List) {
availableIds = options
.whereType<Map<String, dynamic>>()
.map((o) => o['id'] as int? ?? -1)
.where((id) => id >= 0).toList();
}
}
throw SettingRejectedError(
settingId: settingId, rejectedOption: optionValue,
availableOptionIds: availableIds);
}
if (statusCode == 500 && attempt < maxRetries) {
await Future.delayed(Duration(milliseconds: attempt * 500)); // lineaarinen viive
continue;
}
}
rethrow;
}
}
}
Kameran ilmoittamia vaihtoehtoja käytetään palvelukerroksessa sen oman ominaisuusluettelon uudelleenkirjoittamiseen, jos se havaitsee SettingRejectedErrorin. Kunkin mallin osalta aloitamme optimistisella, kovakoodatulla ominaisuustaululla ja annamme kameran korjata meitä koko ajon ajan. Se on käytännöllisempää ja itsekorjaavampaa kuin yrittää simuloida GoPro-kameran koko asetusmatriisia etukäteen.
3. Keep-alive: ajastin, jota et saa unohtaa
Noin 60 sekunnin käyttämättömyyden jälkeen GoPro WiFi AP lopettaa istuntosi. Täysin toimiva yhteytesi kuolee hiljaa istunnon aikana, jos et "tökkää" sitä. Siksi käynnistämme kaksi ajastinta yhdistettäessä:
- Keep-alive 30 sekunnin välein → GET /gopro/camera/keep_alive
- Tilakysely 5 sekunnin välein → GET /gopro/camera/state
void _startKeepAlive() {
_keepAliveTimer?.cancel();
_keepAliveTimer = Timer.periodic(
GoProHttpConstants.keepAliveInterval, // 30 s
(_) => _sendKeepAlive()
);
}
Future<void> _sendKeepAlive() async {
if (!isConnected || _isDisposed) return;
try {
await _httpClient.keepAlive();
} catch (e) {
if (!_isDisposed) _handleDisconnection(); // epäonnistunut keep-alive == yhteys on katkaistu
}
}
Epäonnistunut keep-alive on myös katkaisuviestimme – se on luotettavin tapa huomata, että käyttäjä on siirtynyt WiFi-kantaman ulkopuolelle.
4. Kameran hallinta: suljin, tila ja MAX 2 -objektiivin tanssi
Suljin on helppo osa – käynnistä, odota 500 ms, kysy tila uudelleen. Valokuvaus on sama suljin/käynnistys; saatko videon vai valokuvan riippuu nykyisestä tilasta.
Tilan vaihto on kiinnostavaa, koska GoPro MAX 2:n "tila" on todella kaksi asetusta: esiasetusryhmä (Video = 1000, Photo = 1001, Timelapse = 1002) sekä objektiivi (Asetus 194: 0 = yksi objektiivi/HERO, 1 = 360). Tämän oikein tekeminen vaati useita epäselviä sääntöjä, jotka oli leivottu koodiin:
- Järjestä tilamuutokset. Nopeat alatilatapaukset jonotetaan Completer-ketjun kautta, jotta ne eivät voi kilpailla keskenään epäyhtenäiseen tilaan.
- Pysäytä suljin ensin. Laitteisto hylkää esiasetusmuutokset HTTP 400:lla tallennuksen aikana, joten pysäytämme ennakoidusti koodauksen ennen vaihtamista.
- Järjestys ja viiveet ovat tärkeitä. MAX 2:ssa: aseta esiasetusryhmä ensin (+400 ms), sitten objektiivi asetuksen 194 kautta (+1500 ms). Tämä 1,5 sekunnin tauko on todellinen – se on fyysisen objektiivikokoonpanon siirtymä.
- Luota tarkoitettuun tilaan, älä kyselytilaan. Seuraamme _lastIntendedIs360 / _lastIntendedPresetGroup -muuttujia ja vertaamme niihin, koska 5 sekunnin tilakysely voi antaa vanhentuneen tilannekuvan kesken siirtymän.
On myös todella ovela GoPro-ominaisuus, joka on mainittava: resoluutio-ID:t riippuvat kuvasuhteesta. 4K on vaihtoehto 1 suhteessa 16:9, 109 suhteessa 9:16 ja 112 suhteessa 4:3. Joten kuvasuhteen vaihtamisen jälkeen kysymme ominaisuudet uudelleen – sama ID tarkoittaisi muuten eri resoluutiota.
5. Median listaaminen
GET /gopro/media/list palauttaa hakemistoon ryhmitellyn rakenteen kuuluisan ytimekkäillä avaimilla: d = hakemisto, fs = tiedostot, n = tiedostonimi, s = koko, cre/mod = unix-sekuntiaikaleimat, g = ryhmä-ID, glrv = matalaresoluutioisen proxyn koko.
final media = response['media'] as List<dynamic>?;
for (final dir in media) {
final directory = dirMap['d'] as String? ?? '';
final filesList = dirMap['fs'] as List<dynamic>?;
for (final fileJson in filesList) {
files.add(GoProCameraFile.fromJson(directory, fileMap));
}
}
files.sort((a, b) => b.modifiedAt.compareTo(a.modifiedAt)); // uusimmat ensin
Mediatyyppi päätellään tiedostonimen etuliitteestä + tiedostopäätteestä: GS = 360-video, GX = HERO-video, GT = timelapse, .360 = pallomainen video, .GPR/.RAW = raakakuva. Nämä etuliitteet ovat tärkeitä myöhemmin, koska .360-tiedostot käyttäytyvät eri tavalla kuin kaikki muut.
6. Suoratoistolataukset: turbo, edistyminen ja oheistiedostot
Todellinen tavusiirto tapahtuu dio.download()-toiminnolla suoraan tiedostopolkuun, pitkällä vastaanottoaikakatkaisulla, koska 1 Gt:n .360-leikkeen siirto kestää jonkin aikaa:
Future<void> downloadMediaToFile(
String directory, String filename, String destPath,
{void Function(int received, int total)? onProgress}) async {
await _dio.download(
'/videos/DCIM/$directory/$filename',
destPath,
onReceiveProgress: onProgress,
options: Options(receiveTimeout: const Duration(minutes: 30))
);
}
Tämän ytimen ympärillä siirtopalvelu orkestroi todellisen maailman yksityiskohdat:
Turbo-siirto. Ennen lataamista otamme turbo-tilan käyttöön (turbo_transfer?p=1) – GoPro vaihtaa 5 GHz WiFi-verkkoon huomattavasti nopeampia siirtoja varten – ja poistamme sen käytöstä finally-lohkon avulla, jotta se siivotaan aina, jopa virhetilanteessa.
Edistyminen + nopeus, kuristettu. Siirtonopeuden laskeminen jokaisessa onReceiveProgress-takaisinkutsussa on tuhlailevaa ja nykivää, joten laskemme sen uudelleen vain 500 ms välein:
onProgress: (received, total) {
if (_isCancelled) return;
final now = DateTime.now();
String? speed;
if (now.difference(lastSpeedUpdate).inMilliseconds >= 500) {
final bytesPerSecond = (received - lastBytes) /
(now.difference(lastSpeedUpdate).inMilliseconds / 1000);
speed = _formatSpeed(bytesPerSecond.round());
lastSpeedUpdate = now; lastBytes = received;
}
_transferController.add(GoProFileTransfer(
status: FileTransferStatus.downloading,
progress: total > 0 ? received / total : 0,
speed: speed, currentBytes: received, totalBytes: total));
}
Peruutus on yhteistyöhön perustuva _isCancelled-lippu; peruutettaessa poistamme osittaisen tiedoston ja lähetämme peruutustapahtuman.
Oheistiedostot. Tätä osaa et löydä dokumentaatiosta. Yksiobiiviselle .MP4-tiedostolle haemme myös .LRV:n (GoPron tallentama matalaresoluutioinen proxy koko videon rinnalle) ja .THM-pienoiskuvan, ja indeksoimme sitten .LRV:n nopeaa sovelluksen sisäistä toistoa varten. Kaksi nimeämiseen liittyvää kompastuskiveä osuvat tässä:
- HERO8+ nimeää proxyn uudelleen: GX######.MP4 / GH######.MP4 muuttuu muotoon GL######.LRV (toinen merkki vaihtuu L:ksi), ja vanhemmille kameroille on samanniminen varajärjestely.
- .360-tiedostot eivät tue lainkaan pienoiskuva- tai screennail-päätepisteitä. Palaamme .THM-oheistiedostoon, ja viimeisenä keinona lataamme itse tiedoston. Ja koska .360-tiedosto on kaksoiskalansilmäkuva, rajaamme pienoiskuvan etuobjektiiviin – taustalla eristetyssä compute()-kutsussa, jotta käyttöliittymä ei koskaan nyi.
Siirtotila paljastetaan Riverpod-ilmoittimen kautta, joka tilaa tiedosto- ja siirtovirrat, pitää valmiin siirron näkyvissä 2 sekuntia ja tuo virheet esiin 5 sekunnin ajaksi. Huomionarvoista: ohjaus ja tiedonsiirto käyttävät kahta riippumatonta Dio-asiakasohjelmaa samaan kameraan, joten lataus voi olla käynnissä samalla kun tilakysely ja keep-alive jatkuvat.
7. Kaksi kompastuskiveä, joita ei löydy mistään ohjeesta
Selväkielinen HTTP on oletuksena estetty moderneissa Android-laitteissa
http://10.5.5.9:8080 on selväkielinen, ja Android 9+ estää selväkielisen liikenteen oletuksena. Sinun on sallittava se erikseen:
<!-- AndroidManifest.xml -->
<application android:usesCleartextTraffic="true" ... >
plus network_security_config.xml-tiedosto, jonka peruskonfiguraatio sallii selväkielisen liikenteen (voit silti pakottaa HTTPS:n omiin pilvi-/taustaohjelmadomain-osoitteisiisi). Jos tämä unohtuu, jokainen GoPro-kutsu epäonnistuu hämmentävällä yhteysvirheellä.
Puuttuva verkkosidonta – suunnittelun hauras kohta
Tässä on rehellinen osuus. Moderneissa Android-laitteissa, kun liityt GoPro AP:hen, puhelin pitää yleensä matkapuhelinverkon oletusverkkona, koska GoPro AP:lla ei ole internet-yhteyttä. Tämä tarkoittaa, että HTTP-pyyntö osoitteeseen 10.5.5.9 voi reitittyä matkapuhelinverkon kautta ja epäonnistua – vaikka olisitkin "yhdistetty" kameraan.
Vankka korjaus on natiivi: kutsu ConnectivityManager.bindProcessToNetwork(goProNetwork) ennen siirtoja ja bindProcessToNetwork(null) jälkeen. Puhtaalla Dart-pohjaisella GoPro-polulla ei ole vastaavaa – ja se yleensä toimii silti, koska käyttöjärjestelmä yleensä reitittää kiinteän 10.5.5.9-osoitteen tukiasemaan. Mutta se on arkkitehtuurin haurain osa, ja joissakin laitteissa/Android-versioissa se on ensimmäinen asia, joka menee rikki. Jos julkaiset tämän, lisää verkkosidonta – se on ainoa kohta, jossa 100 % Dart-pohjaisuus maksaa luotettavuuden.
Opit
- Et tarvitse BLE:tä. Jos käyttäjä voi liittyä AP:hen, Open GoPro HTTP API antaa sinulle täyden hallinnan ja nopeat siirrot puhtaalla Dartilla.
- Tunnista TCP-tutkalla, älä SSID-skannauksella – ei sijaintilupaa, ei alustakohtaista koodia.
- Keep-alive 30 sekunnin välein, tai istunto kuolee. Käsittele epäonnistunutta keep-alivea katkaisuviestinäsi.
- Anna kameran korjata sinua. 403-available_options-itsekorjaussilmukka on parempi kuin GoPro-kameran koko asetusmatriisin mallintaminen.
- Tilan vaihto on tilakone – järjestä se, lopeta tallennus ensin, kunnioita objektiivin siirtymäviiveitä ja luota tarkoitettuun tilaan kyselytilan sijaan.
- Oheistiedostot ja .360-ominaisuudet ovat niitä, joissa ongelmat piilevät – LRV-uudelleennimeäminen, ei pienoiskuvapäätepistettä, kaksoiskalansilmärajaukset.
- Kaksi infrastruktuuriin liittyvää kompastuskiveä upottavat sinut hiljaa: selväkielisen liikenteen konfigurointi ja (erityisesti) prosessin sitominen kameran verkkoon, jotta pyynnöt eivät karkaa matkapuhelinverkon kautta.
Hyöty: puhelin, joka yhdistyy GoPro-kameraan WiFi-yhteydellä, ohjaa kaikkia toimintoja ja suoratoistaa gigatavuja kuvamateriaalia live-edistyksen kera – ja lähes kaikki tämä tapahtuu hyvin strukturoidulla Dartilla keskustellen osoitteen http://10.5.5.9:8080 kanssa.

