Ei SDK:ta, ei BLE-paritustanssia – vain kameran kiinteä IP, Dio-client ja muutama sudenkuoppa, jotka pilaavat päiväsi huomaamatta, jos et tiedä niistä.
Miksi WiFi, eikä Bluetooth?
Useimmat "yhdistä GoProhun" -oppaat alkavat Bluetooth Low Energyllä: skannaus, paritus, GATT-ominaisuuksien vaihto, sitten BLE:n käyttö WiFi:n käynnistämiseen, ja sen jälkeen siirto tapahtuu joka tapauksessa WiFi:n kautta. Se toimii, mutta siinä on paljon seremoniaa — ja BLE on hidas ja hankala siihen, mitä käyttäjät todella haluavat, eli kameran hallintaan ja kuvamateriaalin nopeaan siirtämiseen.
Tässä sovelluksessa ohitamme siis BLE:n kokonaan. Käyttäjä liittyy GoPron WiFi-tukiasemaan puhelimensa asetuksista, ja siitä eteenpäin kaikki on tavallista HTTP:tä kameran Open GoPro APIa vastaan kiinteässä osoitteessa:
http://10.5.5.9:8080
Kameran ohjaus, asetukset, median listaus ja tiedostojen lataus ovat kaikki HTTP GET -pyyntöjä tähän IP-osoitteeseen. Koko integraatio on 100-prosenttisesti Dart-kieltä dio-paketin kanssa — ei lainkaan natiivia Kotlinia/Swiftiä. Tämä on otsikko, ja kuten lopussa näemme, se on myös ainoa kohta, jossa suunnittelu on hauras.
Kulku:
liity GoPron WiFi AP:hen (puhelimen asetukset)
→ tunnista, että kamera on saavutettavissa (TCP-koetin osoitteeseen 10.5.5.9:8080)
→ yhdistä + tunnista malli (GET /gopro/camera/state)
→ käynnistä keep-alive (30s) + tilakysely (5s) ajastimet
→ ohjaus: suljin / tila / asetukset
→ listaa media (GET /gopro/media/list)
→ lataa turbo-tilassa + edistyminen (dio.download)
1. Kameran havaitseminen ilman WiFi-skannausoikeuksia
Ensimmäinen vaisto on luetteloida WiFi SSID:t ja etsiä sellaista, joka alkaa GoProlla. Älä tee niin — se tuo mukanaan sijaintioikeudet ja alustakohtaiset WiFi-skannaus-API:t.
GoPro sijaitsee aina samassa IP-osoitteessa, joten havaitseminen on yksinkertaista: voinko avata TCP-socketin osoitteeseen 10.5.5.9:8080? Ei HTTP:tä, ei oikeuksia, ei SSID-parsintaa.
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 streamina. Yhteysnäyttö tilaa muutokset ja siirtyy "yhdistetty"-tilaan heti, kun socket avautuu. Koska todellista laitetunnistusta ei ole, palvelu syntetisoi virtuaalisen CameraDevice:n (kiinteän IP-osoitteen perusteella), jotta lopun kamera-abstraktion ei tarvitse tietää eroa.
2. HTTP-client: Dio ja Open GoPro -päätepisteet
Client on ohut Dio-wrapper. Huomaa lyhyt connect timeout (olet LAN-verkossa — jos se on hidas, se on rikki) ja runsas receive timeout 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 ihanan yhdenmukainen: kaikki on HTTP GET -pyyntöjä, jopa komennot ja asetusten muutokset. Tässä ovat päätepisteet, joita tämä sovellus todellisuudessa 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) |
| Thumbnail / screennail | GET /gopro/media/thumbnail / .../screennail | path |
| Turbo transfer | 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 ovat 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-itsestäänkorjausmalli (tämän puhtain temppu)
Et voi tietää etukäteen, mitkä asetukset ovat kelvollisia kameran nykyisessä tilassa – resoluutio, joka on sallittu 16:9-kuvasuhteessa, on kielletty 9:16-kuvasuhteessa, FPS-asetukset riippuvat resoluutiosta ja niin edelleen. GoPro kertoo tämän sinulle vaikean kautta: se palauttaa HTTP 403:n luettelolla vaihtoehdoista, jotka olisivat olleet kelvollisia, ja HTTP 500:n, kun se on vain hetkellisesti varattu.
Joten jäsenämme 403-body:n tyypitettyyn virheeseen 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 takaisinkytkentä
continue;
}
}
rethrow;
}
}
}
Kameran ilmoittamia vaihtoehtoja käyttää palvelukerros oman ominaisuusluettelonsa uudelleenkirjoittamiseen, jos se havaitsee SettingRejectedError:in. Jokaiselle mallille aloitamme optimistisella, kovakoodatulla ominaisuustaulukolla ja annamme kameran korjata meitä ajon aikana. Se on käytännöllisempää ja itsekorjaavampaa kuin yrittää simuloida GoPron koko asetusmatriisia etukäteen.
3. Keep-alive: ajastin, jota ei saa unohtaa
Noin 60 sekunnin käyttämättömyyden jälkeen GoPron WiFi AP lopettaa istuntosi. Täysin toimiva yhteytesi katkeaa huomaamatta kesken istunnon, jos et pidä sitä elossa. Siksi käynnistämme kaksi ajastinta yhteyden muodostamisen yhteydessä:
- 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, // 30s
(_) => _sendKeepAlive(),
);
}
Future<void> _sendKeepAlive() async {
if (!isConnected || _isDisposed) return;
try {
await _httpClient.keepAlive();
} catch (e) {
if (!_isDisposed) _handleDisconnection(); // epäonnistunut keep-alive == olemme yhteydettömiä
}
}
Epäonnistunut keep-alive on myös katkeamissignaalimme — se on luotettavin tapa huomata, että käyttäjä on poistunut WiFi-kantaman ulottuvilta.
4. Kameran ohjaus: suljin, tila ja MAX 2 -objektiivin tanssi
Suljin on helppo osa — käynnistä, odota 500 ms, kysy tila uudelleen. Valokuvan ottaminen on sama suljin/käynnistys; saatko videon vai valokuvan, riippuu nykyisestä tilasta.
Tilan vaihtaminen on mielenkiintoinen kohta, koska "tila" GoPro MAX 2:ssa on oikeasti kaksi asetusta: esiasetusryhmä (Video = 1000, Photo = 1001, Timelapse = 1002) sekä objektiivi (Setting 194: 0 = yksi objektiivi/HERO, 1 = 360). Tämän saaminen oikein vaati useita koodiin upotettuja ei-ilmeisiä sääntöjä:
- Sarjallista tilan muutokset. Nopeat alitilan napautukset jonotetaan Completer-ketjun kautta, jotta ne eivät voi kilpailla keskenään epäjohdonmukaiseen tilaan.
- Pysäytä suljin ensin. Firmware hylkää esiasetusten muutokset HTTP 400 -virheellä tallennuksen aikana, joten lopetamme ennakoivasti koodauksen ennen vaihtoa.
- Järjestyksellä ja viiveillä on väliä. MAX 2:ssa: aseta esiasetusryhmä ensin (+400 ms), sitten objektiivi Setting 194:n kautta (+1500 ms). Tämä 1,5 sekunnin tauko on todellinen — se on fyysisen objektiivikokoonpanon siirtymistä.
- Luota tarkoitettuun tilaan, älä kysyttyyn tilaan. Seuraamme _lastIntendedIs360 / _lastIntendedPresetGroup -muuttujia ja vertaamme niitä, koska 5 sekunnin tilakysely voi antaa sinulle vanhentuneen tilannekuvan kesken siirtymän.
Lisäksi on syytä mainita todella ovela GoPro-ominaisuus: resoluutio-ID:t riippuvat kuvasuhteesta. 4K on vaihtoehto 1 kuvasuhteessa 16:9, 109 kuvasuhteessa 9:16 ja 112 kuvasuhteessa 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 tiiviillä avaimilla: d = directory, fs = files, n = filename, s = size, cre/mod = unix-sekuntiaikaleimat, g = group id, glrv = the low-res-proxy size.
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)); // uusin 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. Striimaavat lataukset: turbo, edistyminen ja oheistiedostot
Todellinen tavusiirto tapahtuu dio.download():lla suoraan tiedostopolkuun, pitkällä receive timeoutilla, koska 1 GB:n .360-leikkeen lataaminen 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 latausta kytkemme päälle turbo-tilan (turbo_transfer?p=1) — GoPro vaihtaa 5 GHz WiFi:in huomattavasti nopeampia siirtoja varten — ja kytkemme sen pois päältä finally-lohossa, jotta se siivotaan aina, myös virhetilanteessa.
Edistyminen + nopeus, rajoitettu. Siirtonopeuden laskeminen jokaisessa onReceiveProgress-callbackissa on tuhlausta ja nykimistä, 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; peruutuksen yhteydessä poistamme osittaisen tiedoston ja lähetämme peruutetun tapahtuman.
Oheistiedostot. Tämä on se osa, jota et löydä dokumentaatiosta. Yhden objektiivin .MP4-tiedostolle haemme myös .LRV:n (matalaresoluutioinen proxy, jonka GoPro tallentaa koko videon rinnalle) ja .THM-pikkukuvan, ja sitten indeksoimme LRV:n nopeaa sovelluksen sisäistä toistoa varten. Kaksi nimeämiseen liittyvää sudenkuoppaa iskee tässä:
- HERO8+-kamerat nimeävät proxyn uudelleen: GX######.MP4 / GH######.MP4 muuttuu muotoon GL######.LRV (toinen merkki vaihtuu L:ksi), vanhemmissa kameroissa käytetään samaa nimeä.
- .360-tiedostot eivät tue pikkukuva- tai screennail-päätepisteitä ollenkaan. Turvaudumme .THM-oheistiedostoon, ja viimeisenä keinona lataamme itse tiedoston. Ja koska .360 on kaksoiskalansilmäkuva, rajaamme pikkukuvan etummaiseen objektiiviin — taustalla erillään compute()-kutsun avulla, jotta käyttöliittymä ei koskaan nyi.
Siirron tila paljastetaan Riverpod-notifierin kautta, joka tilaa tiedosto- ja siirtovirtoja, pitää valmiin siirron näkyvissä 2 sekuntia ja tuo virheet esiin 5 sekunnin ajan. Huomionarvoista: ohjaus ja tiedostonsiirto käyttävät kahta itsenäistä Dio-clientiä samaan kameraan, joten lataus voi olla käynnissä samalla kun tilakysely ja keep-alive jatkuvat.
7. Kaksi sudenkuoppaa, joita ei löydy mistään oppaasta
Cleartext HTTP on oletuksena estetty moderneissa Androideissa
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" ... >
lisäksi network_security_config.xml, jonka peruskokoonpano sallii cleartext-liikenteen (voit silti pakottaa HTTPS:n omille pilvi-/taustajärjestelmäverkkotunnuksillesi). Jos unohdat tämän, jokainen GoPro-kutsu epäonnistuu hämmentävällä yhteysvirheellä.
Puuttuva verkkosidonta – suunnittelun heikko kohta
Tässä tulee rehellinen osuus. Moderneissa Androideissa, kun liityt GoPro AP:hen, puhelin pitää yleensä mobiiliyhteyden oletusverkkona, koska GoPro AP:lla ei ole internet-yhteyttä. Tämä tarkoittaa, että HTTP-pyyntö osoitteeseen 10.5.5.9 voidaan reitittää mobiiliverkon kautta ja se epäonnistuu — vaikka olisitkin "yhdistetty" kameraan.
Vankka korjaus on natiivi: kutsu ConnectivityManager.bindProcessToNetwork(goProNetwork) ennen siirtoja ja bindProcessToNetwork(null) jälkeenpäin. Puhtaalla Dart-pohjaisella GoPro-polulla ei ole vastaavaa — ja se yleensä toimii joka tapauksessa, koska käyttöjärjestelmä yleensä reitittää kiinteän 10.5.5.9-osoitteen AP:hen. Mutta tämä on arkkitehtuurin haurain osa, ja joissakin laitteissa/Android-versioissa se on ensimmäinen asia, joka menee rikki. Jos otat tämän käyttöön, lisää verkkosidonta — se on ainoa kohta, jossa 100-prosenttisesti Dart-kielen käyttäminen heikentää luotettavuutta.
Opetukset
- Et tarvitse BLE:tä. Jos käyttäjä voi liittyä AP:hen, Open GoPro HTTP API antaa täyden hallinnan ja nopeat siirrot puhtaalla Dartilla.
- Tunnista TCP-koettimella, ei SSID-skannauksella — ei sijaintioikeutta, ei alustakohtaista koodia.
- Keep-alive 30 sekunnin välein, muuten istunto kuolee. Käsittele epäonnistunutta keep-alivea katkaisusignaalina.
- Anna kameran korjata sinua. 403-available_options-itsestäänkorjaussilmukka on parempi kuin GoPron koko asetusmatriisin mallintaminen.
- Tilan vaihtaminen on tilakone — sarjallista se, lopeta tallennus ensin, kunnioita objektiivin siirtymäviiveitä ja luota tarkoitettuun tilaan kysytyn tilan sijaan.
- Oheistiedostot ja .360-erikoisuudet ovat missä haasteet piilevät — LRV:n uudelleennimeäminen, ei pikkukuva-päätepistettä, kaksoiskalansilmärajaukset.
- Kaksi infrastruktuurin sudenkuoppaa upottavat sinut hiljaisesti: cleartext-liikenteen konfigurointi, ja (erityisesti) prosessin sitominen kameran verkkoon, jotta pyynnöt eivät karkaa mobiiliverkon kautta.
Palkintona: puhelin, joka yhdistyy GoProhon WiFi:n kautta, ohjaa jokaista toimintoa ja striimaa gigatavuja kuvamateriaalia siitä reaaliaikaisella edistyksellä — ja melkein kaikki tämä on vain hyvin jäsenneltyä Dart-koodia, joka puhuu osoitteeseen http://10.5.5.9:8080.

