Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Creality K2: custom WebRTC feed #452

Open
jamincollins opened this issue Dec 26, 2024 · 42 comments
Open

Creality K2: custom WebRTC feed #452

jamincollins opened this issue Dec 26, 2024 · 42 comments
Labels
feature-request New feature or request

Comments

@jamincollins
Copy link

Feature Request

Problem Description

So, the Creality K2 has something of an odd/custom camera feed.
The camera is available on http://printer_ip:8000/

I can't seem to find a way in the current Mobileraker UI to reference this camera.

Proposed Solution

Copy the logic of ether go2rtc or camerastreamer webrtc code and call to /call/webrtc_local and thats it

Alternatives Considered

Additional Context

Here is the script from the above referenced page that shows the call to /call/webrtc_local

     <script> 
       var pc = new RTCPeerConnection({ 
         iceServers: [{urls: 'stun:stun.l.google.com:19302'}] 
       }); 
       var log = msg => { console.log(msg); }; 
       function sendOfferToCall(sdp) { 
         var xhttp = new XMLHttpRequest(); 
         xhttp.onreadystatechange = function() { 
           if (this.readyState == 4 && this.status == 200) { 
             let res = JSON.parse(atob(this.responseText)); 
             console.log(res); 
             if(res.type == 'answer') { 
               pc.setRemoteDescription(new RTCSessionDescription(res)); 
             } 
           } 
         }; 
         xhttp.open('POST', '/call/webrtc_local'); 
         xhttp.setRequestHeader('Content-Type', 'plain/text'); 
         xhttp.send(btoa(JSON.stringify({'type': 'offer', 'sdp': sdp}))); 
       } 
       pc.ontrack = function (event) { 
         var el = document.getElementById('remoteVideos'); 
         el.srcObject = event.streams[0]; 
         el.autoplay = true; 
         el.controls = true; 
         el.muted = true; 
       }; 
       pc.oniceconnectionstatechange = e => log(pc.iceConnectionState); 
       pc.onicecandidate = event => { 
         if(event.candidate === null) sendOfferToCall(pc.localDescription.sdp) 
       }; 
       pc.addTransceiver('video', {'direction': 'sendrecv'}) 
       pc.createOffer().then(d => pc.setLocalDescription(d)).catch(log); 
     </script> ```

---

**Checklist**
To help us understand your feature request, please ensure you've covered the following points:
- [x] Provided a clear description of the problem you're facing.
- [X] Clearly outlined the proposed solution or feature.
- [ ] Mentioned any alternative solutions or ideas you've considered.
- [X] Included any relevant context or supporting materials.

Your input is valuable in shaping the development of our project. Thank you for taking the time to submit your feature request!
@jamincollins jamincollins added the feature-request New feature or request label Dec 26, 2024
@Clon1998
Copy link
Owner

I already implemented multiple webRtc sources. Just open up the printer settings in the app and add a webcam

image

@jamincollins
Copy link
Author

I've tried them, none of them seem to work for the Creality K2. Happy to provide whatever information will help.

@jamincollins
Copy link
Author

Screenshot_2024-12-26-17-46-14-97_424ec53f7d515395b4c3ef1aaa0a00a2.jpg

Screenshot_2024-12-26-17-45-48-09_424ec53f7d515395b4c3ef1aaa0a00a2.jpg

@Clon1998
Copy link
Owner

Can you provide me the log files?
Seems like something during the WebRtc handshake is failing due to a type error.

@Clon1998
Copy link
Owner

Hey @jamincollins
I had another look at the code you posted. Is this part of either the Mainsail or Fluidd project or is it a code section of the modified Mainsail fork of Creality and they are using (once again) a custom solution to annoy everyone?

If later is the case, I will need to add a custom WebRtc Manager to support their handshake format.
Can you provide me a network trace from your browser so I can replicate their messaging commands without owning/having access to a K2?

@jamincollins
Copy link
Author

The camera is a completely stand alone app/binary on the K2, /usr/bin/webrtc. The snippet is from the web page buried in this app/binary.

Here's the recording you requested, but it amounts to the same thing I detailed above:
k2_Archive [24-12-27 12-08-10].har.zip

@Clon1998
Copy link
Owner

The camera is a completely stand alone app/binary on the K2, /usr/bin/webrtc. The snippet is from the web page buried in this app/binary.

Here's the recording you requested, but it amounts to the same thing I detailed above:

k2_Archive [24-12-27 12-08-10].har.zip

I will look into it. If it's really just these three requests it should be an easy thing to add.

@jamincollins
Copy link
Author

It should be, the only other possible niggle may be scaling the feed you get, but I suspect that's already handled in your existing code.

@jamincollins
Copy link
Author

Just wanted to check in on this.

@Clon1998
Copy link
Owner

Clon1998 commented Jan 7, 2025

Hey @jamincollins
Thanks for the reminder, I actually started to implement this right now.
I can hopefully finish the implementation as it seems like creality is just using a camera-streamer and all requests/responses are Base64 encoded.

I will just add a new type that can handle this.

Clon1998 added a commit that referenced this issue Jan 7, 2025
feat: Add Creality WebRtc

Fix #452
@Clon1998
Copy link
Owner

Clon1998 commented Jan 7, 2025

I have triggered a new build on codemagic.
I will let you know once it is done and provide you an APK via GitHub

@Clon1998
Copy link
Owner

Clon1998 commented Jan 7, 2025

Can you test the new creality apk on the release page:

https://github.com/Clon1998/mobileraker/releases/tag/android2.8.4

@Clon1998
Copy link
Owner

Clon1998 commented Jan 8, 2025

@jamincollins can you give me feedback if the new webcam type is working?

I tried it with my standard klipper printer and it seems like the mentioned path is also available. However, my camera-streamer responds with a 405 code.

@jgmdean
Copy link

jgmdean commented Jan 9, 2025

Happy to test this on iPhone.

Oddly enough Safari, Chrome, and Firefox on MacOS all show the stream when accessing the :8000 URL directly. Ditto Safari on iPhone.

@Clon1998
Copy link
Owner

Clon1998 commented Jan 9, 2025

Happy to test this on iPhone.

Oddly enough Safari, Chrome, and Firefox on MacOS all show the stream when accessing the :8000 URL directly. Ditto Safari on iPhone.

Eure, I just added the build to TestFlight which is now awaiting approval from apple. So it might take up to 24hrs

https://testflight.apple.com/join/h9JwuyR0

@jamincollins
Copy link
Author

jamincollins commented Jan 9, 2025

Sorry for the delay.

I just tested this. I see the WebRtcCreality, and I've set the streaming URL to http://k2:8000.

I get:

FormatException: Invalid character (at character 1)
<!DOCTYPE html>
^

@Clon1998
Copy link
Owner

Clon1998 commented Jan 9, 2025

Sorry for the delay.

I just tested this. I see the WebRtcCreality, and I've set the streaming URL to http://k2:8000.

I get:


FormatException: Invalid character (at character 1)

<!DOCTYPE html>

^

Can you provide me the device logs

@Clon1998
Copy link
Owner

Clon1998 commented Jan 9, 2025

Also try to include the "call/webrtc_local" in the webcam url

@jamincollins
Copy link
Author

@jgmdean
Copy link

jgmdean commented Jan 10, 2025

Happy to test this on iPhone.

Eure, I just added the build to TestFlight which is now awaiting approval from apple. So it might take up to 24hrs

https://testflight.apple.com/join/h9JwuyR0
No Luck:

IMG_0520
IMG_0521

@jgmdean
Copy link

jgmdean commented Jan 10, 2025

Happy to test this on iPhone.

Eure, I just added the build to TestFlight which is now awaiting approval from apple. So it might take up to 24hrs

https://testflight.apple.com/join/h9JwuyR0

No luck.
image
image

Happy to test this on iPhone.

Eure, I just added the build to TestFlight which is now awaiting approval from apple. So it might take up to 24hrs
https://testflight.apple.com/join/h9JwuyR0
No Luck:

I have the modification from the script at https://github.com/jamincollins/k2-improvements
To get the webcam working in Fluidd. That shouldn’t make a difference, right?

@jamincollins
Copy link
Author

I have the modification from the script at https://github.com/jamincollins/k2-improvements To get the webcam working in Fluidd. That shouldn’t make a difference, right?

No it should not, it doesn't alter anything about the camera feed on :8000, it only implements the same sort of thing we are asking for here.

@Clon1998
Copy link
Owner

@jamincollins

You did not set the correct cam path.
Either use a relative address /call/webrtc_local or use an absolute address that includes the path like http://k2:8000/call/webrtc_local for the stream URL.

@jamincollins
Copy link
Author

Not sure how the relative is going to find the right port, but here they are:
k2_8000.txt
k2_relative.txt

@jamincollins
Copy link
Author

Here's the changes that were made to Fluidd if that perhaps helps:
fluidd.patch.gz

@Clon1998
Copy link
Owner

Not sure how the relative is going to find the right port, but here they are: k2_8000.txt k2_relative.txt

Seems like you're cam config within mobileraker is still wrong as both versions still use the same URI:
💡 [CrealityRtcManager#660642722(http://k2:8000/)] Sending offer to cam at http://k2:8000/

image
image

@jamincollins
Copy link
Author

Screenshot_2025-01-11-13-22-19-47_a1b1bbe5f63d5b96c1a0f87c197ebfae~2.jpg

@Clon1998
Copy link
Owner

Clon1998 commented Jan 11, 2025

What you highlighted is the response of the demo page/html of it because the url it's using is k2:8000 without the proper webcam path.

Open sidebar -> Tap gear icon at the top -> webcam section -> click on webcam name -> change stream url to absolute path -> hit save

image

@jamincollins
Copy link
Author

Screenshot (Jan 12, 2025 2_29_07 PM)
Screenshot (Jan 12, 2025 2_29_34 PM)
k2_relative_take2.txt

@jamincollins
Copy link
Author

Screenshot (Jan 12, 2025 2_35_28 PM)
Screenshot (Jan 12, 2025 2_35_42 PM)
k2_fully_scoped.txt

@jamincollins
Copy link
Author

Screenshot (Jan 12, 2025 2_38_14 PM)
Screenshot (Jan 12, 2025 2_38_33 PM)
k2_just_port .txt

@jgmdean
Copy link

jgmdean commented Jan 13, 2025

@jamincollins

You did not set the correct cam path. Either use a relative address /call/webrtc_local or use an absolute address that includes the path like http://k2:8000/call/webrtc_local for the stream URL.
Screenshot 2025-01-13 at 01 10 49

http://192.168.123.39:8000/
works in both fluid and in a browser directly

@Clon1998
Copy link
Owner

@jamincollins

You did not set the correct cam path. Either use a relative address /call/webrtc_local or use an absolute address that includes the path like http://k2:8000/call/webrtc_local for the stream URL.

Screenshot 2025-01-13 at 01 10 49

http://192.168.123.39:8000/

works in both fluid and in a browser directly

For nearly all webcam config setups I made them as customizable as possible. That's why for the K2 cam I once again did not include code to append the relative path to its call url.

The error is a different one again. Can you provide me the log file of the app so I can have a look where the type error is in my code.

@jamincollins
Copy link
Author

jamincollins commented Jan 13, 2025

I've provided this version and log file
#452 (comment)

@Clon1998
Copy link
Owner

I've provided this version and log file #452 (comment)

Thanks,
i was able to locate the issue and fix it.
I will rebuild a new APK and IOS version for you guys to try.

@Clon1998
Copy link
Owner

@jamincollins please give the creality-webrtc-v2.apk a try.

@jamincollins
Copy link
Author

@Clon1998
Copy link
Owner

@jamincollins creality-webrtc-v3.apk.

Ensured I actually use the SDP constraints I defined lol....

@jamincollins
Copy link
Author

Now it continually says "Trying to connect...":
k2_trying_to_connect.txt

@Clon1998
Copy link
Owner

Now it continually says "Trying to connect...": k2_trying_to_connect.txt

Okay, I will verify that tomorrow. Might be that the webrtc lib I am using simply does not support it.
The log states that the webrtc handling is done. So by default webrtc will do its magic and start working.

@Clon1998
Copy link
Owner

Maybe I am blind but I can not find what else besides a platform/lib limitation is the error now:

/*
 * Copyright (c) 2023-2025. Patrick Schmidt.
 * All rights reserved.
 */

import 'dart:async';
import 'dart:convert';

import 'package:common/exceptions/mobileraker_exception.dart';
import 'package:common/util/logger.dart';
import 'package:dio/dio.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';

import 'data/webrtc_connection_event.dart';
import 'webrtc_manager.dart';

class CrealityRtcManager implements WebRtcManager {
  final Map<String, dynamic> offerSdpConstraints = {
    'mandatory': {
      'OfferToReceiveAudio': false,
      'OfferToReceiveVideo': true,
    },
    'optional': [],
  };

  final List<Map<String, String>> iceServers = [
    {'url': 'stun:stun.l.google.com:19302'}
  ];

  CrealityRtcManager({required Dio dio, required Uri camUri})
      : _camUri = camUri,
        _dio = dio;

  final Dio _dio;

  final Uri _camUri;

  final RTCVideoRenderer _localRenderer = RTCVideoRenderer();
  String? _remoteId;
  RTCPeerConnection? _pc;

  final StreamController<WebRtcConnectionEvent> _streamController = StreamController();

  @override
  Stream<WebRtcConnectionEvent> get stream => _streamController.stream;

  RTCPeerConnectionState? __connectionState;

  set _connectionState(RTCPeerConnectionState state) {
    if (state == __connectionState) return;
    logger.i(
        '[CrealityRtcManager#${identityHashCode(this)}($_camUri)] RTC _onConnectionState: $__connectionState -> $state');
    __connectionState = state;
    WebRtcConnectionEvent event = switch (state) {
      RTCPeerConnectionState.RTCPeerConnectionStateClosed => WebRtcConnectionEventClosed(),
      RTCPeerConnectionState.RTCPeerConnectionStateFailed => WebRtcConnectionEventFailed(),
      RTCPeerConnectionState.RTCPeerConnectionStateDisconnected => WebRtcConnectionEventDisconnected(),
      RTCPeerConnectionState.RTCPeerConnectionStateNew => WebRtcConnectionEventNew(),
      RTCPeerConnectionState.RTCPeerConnectionStateConnecting => WebRtcConnectionEventConnecting(),
      RTCPeerConnectionState.RTCPeerConnectionStateConnected => WebRtcConnectionEventConnected(_localRenderer),
    };
    if (!_streamController.isClosed) _streamController.add(event);
  }

  @override
  Future<void> startCam() async {
    logger.i('[CrealityRtcManager#${identityHashCode(this)}($_camUri)] Starting Camera connection');
    if (__connectionState == RTCPeerConnectionState.RTCPeerConnectionStateConnected) {
      logger.i('[CrealityRtcManager#${identityHashCode(this)}($_camUri)] Camera already connected');
      return;
    }

    if (__connectionState == RTCPeerConnectionState.RTCPeerConnectionStateConnecting) {
      logger.i('[CrealityRtcManager#${identityHashCode(this)}($_camUri)] Camera already connecting');
      return;
    }

    if (__connectionState == RTCPeerConnectionState.RTCPeerConnectionStateNew) {
      logger.i('[CrealityRtcManager#${identityHashCode(this)}($_camUri)] Camera already new');
      return;
    }

    try {
      // Make sure to reset the connection state to provide a clean UI state!
      _connectionState = RTCPeerConnectionState.RTCPeerConnectionStateNew;
      await _pc?.dispose();
      await _localRenderer.initialize();

      _pc = await _setupPeerConnection();
      // Submit to http://k2:8000/call/webrtc_local
      final answer = await _sendOffer(_pc!);

      await _handleAnswer(answer);

      logger.i('[CrealityRtcManager#${identityHashCode(this)}($_camUri)] Completed webrtc offer and answer sequence');
    } catch (e, s) {
      logger.w('[CrealityRtcManager#${identityHashCode(this)}($_camUri)] Error while trying to start WebRTC cam', e);
      _connectionState = RTCPeerConnectionState.RTCPeerConnectionStateFailed;
      if (!_streamController.isClosed) _streamController.addError(e, s);
    }
  }

  @override
  Future<void> stopCam() async {
    logger.i('[CrealityRtcManager#${identityHashCode(this)}($_camUri)] Stopping PeerConnection');
    return _pc?.close();
  }

  @override
  void toggleAudio([bool? toggleValue]) {
    final next = toggleValue ?? !_localRenderer.muted;
    _localRenderer.srcObject?.getAudioTracks().forEach((t) => t.enabled = next);
  }

  Future<RTCPeerConnection> _setupPeerConnection() async {
    logger.i('[CrealityRtcManager#${identityHashCode(this)}($_camUri)] Setting up PeerConnection');
    return await createPeerConnection({
      'sdpSemantics': 'unified-plan',
      'iceServers': iceServers,
    }, offerSdpConstraints)
      ..onTrack = _onTrack
      ..onIceCandidate = _onIceCandidate
      ..onConnectionState = _onConnectionState
      ..onIceConnectionState = _onIceConnectionState
      ..onSignalingState = _onSignalingState
      ..addTransceiver(
          kind: RTCRtpMediaType.RTCRtpMediaTypeVideo,
          init: RTCRtpTransceiverInit(direction: TransceiverDirection.SendRecv));
  }

  Future<Response<String>> _sendOffer(RTCPeerConnection pc) async {
    final offer = await _pc!.createOffer(offerSdpConstraints);
    await _pc!.setLocalDescription(offer);

    logger.i('[CrealityRtcManager#${identityHashCode(this)}($_camUri)] Sending offer to cam at $_camUri');
    try {
      final data = {
        'type': 'offer',
        'sdp': offer.sdp,
      };

      final encodedData = btoa(data);
      logger.i('[CrealityRtcManager#${identityHashCode(this)}($_camUri)] Sending offer to cam: $encodedData');

      final response = await _dio.postUri<String>(
        _camUri,
        options: Options(contentType: 'text/plain'),
        data: encodedData,
      );

      logger.i('[CrealityRtcManager#${identityHashCode(this)}($_camUri)] Received answer from cam.');
      return response;
    } catch (e) {
      logger.w(
        '[CrealityRtcManager#${identityHashCode(this)}($_camUri)] Caught exception while sending offer to server',
        e,
      );

      rethrow;
    }
  }

  String btoa(Map<String, dynamic> data) {
    final jsonData = jsonEncode(data);
    final b64Data = base64Encode(utf8.encode(jsonData));
    return b64Data;
  }

  Future<void> _handleAnswer(Response<String> answer) async {
    logger.i(
        '[CrealityRtcManager#${identityHashCode(this)}($_camUri)] Handling answer from cam: Code:${answer.statusCode}, ${answer.data}');

    if (answer.data == null) {
      logger.w('[CrealityRtcManager#${identityHashCode(this)}($_camUri)] Received empty answer from server');
      throw MobilerakerException('Received empty answer from server. Please contact the developer.');
    }

    try {
      final base64decode = base64Decode(answer.data!);
      final jsonData = utf8.decode(base64decode);
      final data = jsonDecode(jsonData) as Map<String, dynamic>;

      switch (data) {
        case {'type': != 'answer'}:
          logger.w(
              '[CrealityRtcManager#${identityHashCode(this)}($_camUri)] Received invalid answer type "${data['type']}" from server');
          throw MobilerakerException(
              'Received invalid answer type "${data['type']}" from server. Please contact the developer.');
        case {'sdp': == null}:
          logger.w(
              '[CrealityRtcManager#${identityHashCode(this)}($_camUri)] Received invalid answer from server, missing "sdp"');
          throw MobilerakerException('Received invalid answer sdp from server. Please contact the developer.');
        case {'type': 'answer', 'sdp': String()}:
          await _pc!.setRemoteDescription(RTCSessionDescription(data['sdp'], 'answer'));
          break;
        default:
          logger
              .w('[CrealityRtcManager#${identityHashCode(this)}($_camUri)] Received invalid answer from server: $data');
          throw MobilerakerException('Received invalid answer from server. Please contact the developer.');
      }
    } catch (e, s) {
      logger.w(
        '[CrealityRtcManager#${identityHashCode(this)}($_camUri)] Caught exception while handling answer from server',
        e,
        s,
      );

      rethrow;
    }
  }

  void _onTrack(RTCTrackEvent event) {
    logger.i('[CrealityRtcManager#${identityHashCode(this)}($_camUri)] Received RTCTrackEvent');
    if (_streamController.isClosed) return;
    if (event.track.kind == 'video' && event.streams.isNotEmpty) {
      _localRenderer.srcObject?.dispose();
      _localRenderer.srcObject = event.streams.first;
      // Disable audio by default
      toggleAudio(false);
    }
  }

  void _onIceCandidate(RTCIceCandidate event) async {
    logger.i('[CrealityRtcManager#${identityHashCode(this)}($_camUri)] Received a new ICE event');
    if (_streamController.isClosed) return;
    if (event.candidate != null) return;
    logger.i('[CrealityRtcManager#${identityHashCode(this)}($_camUri)] Received a new ICE event with null candidate');

    // Note that as of 25.11.23 Ice candidates can not be sent to the server
    try {
      var localDescription = await _pc!.getLocalDescription();
      final data = {
        'type': 'offer',
        'sdp': localDescription!.sdp,
      };

      logger.i('[CrealityRtcManager#${identityHashCode(this)}($_camUri)] Sending new offer to cam after ICE event');
      final response = await _dio.postUri<String>(
        _camUri,
        options: Options(contentType: 'text/plain'),
        data: btoa(data),
      );
      await _handleAnswer(response);
    } catch (e) {
      logger.w(
          '[CrealityRtcManager#${identityHashCode(this)}($_camUri)] Caught exception while sending ICECandidate to server',
          e);
      // Some versions of the cam server do not support ICE candidates
    }
  }

  void _onIceConnectionState(RTCIceConnectionState event) {
    logger.i('[CrealityRtcManager#${identityHashCode(this)}($_camUri)] RTC onIceConnectionState: $event');
  }

  void _onSignalingState(RTCSignalingState state) {
    logger.i('[CrealityRtcManager#${identityHashCode(this)}($_camUri)] RTC onSignalingState: $state');
  }

  void _onConnectionState(RTCPeerConnectionState event) {
    _connectionState = event;
  }

  @override
  void dispose() {
    stopCam().ignore();
    _localRenderer.dispose().ignore();
    _streamController.close().ignore();
  }
}

@jgmdean
Copy link

jgmdean commented Jan 19, 2025

Is there a new iOS build you want me to test via TestFlight?

I now have two K2’s so I can test two configs at once. 😎

How do I provide the log file in iOS?

Image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature-request New feature or request
Projects
None yet
Development

No branches or pull requests

3 participants