blog:2023:0713_homectrl_wasm_impl

Working on WASM HomeCtrl implementation

Going back to the WASM implementation of the HomeCtrl tool now, since I just failed with my initial experiment with Kivy. If I can get the WASM version to work at least, then I might be able to use that from my phone for a while (until I find a better solution?)

Youtube video for this article available at:

I spent some time to get the emscripten compilation back on rails, and also updated the RequestManager to support async post requests (in addtion to async get requests), but eventually I could start the updated version of the homectrl app in firefox. Yet, nothing will be retrieved from the servers as I get the following errors:

This is due to the mixed content limitation preventing us from downloading stuff from http urls while we are on an https website (a good thing of course!). To fix this, I think the most appropriate option now is really to start providing the access to the servers through my official webserver (with https) so let's set this up.

I have thus updated the rules in my api.nervtech.org subdomain to redirect to the raspberries as needed:

    location ~ ^/homectrl(.*)$ {
      rewrite           ^/homectrl/(.*) /$1 break;
      proxy_pass http://192.168.0.9:8082;      
    }

    location ~ ^/swimctrl(.*)$ {
      rewrite           ^/swimctrl/(.*) /$1 break;
      proxy_pass http://192.168.0.8:8082;      
    }

Now restarting the nginx server:

cd /mnt/shares/master/containers/nginx_server
docker-compose down
docker-compose up -d

Then I have to use the updated server locations in the homectrl config file:

# This is the main configuration file for HomeView

servers:
  SwimCtrl:
    # base_url: http://localhost
    # base_url: http://192.168.0.8:8082
    base_url: https://api.nervtech.org/swimctrl
  HomeCtrl:
    # base_url: http://192.168.0.9:8082
    base_url: https://api.nervtech.org/homectrl

Testing with the desktop version of homeview:

nvp homeview

And this is still working just fine:

Next I must rebuild the wasm version of homeview to update the config.yml contained in the data. And after that we can give it another try with:

nvp nvl_serve_homeview

And crap… 😂 Still not working…! But this time due to missing CORS headers:

So I simply added a wildcard CORS on the homectrl/swimctrl locations for now:

    location ~ ^/homectrl(.*)$ {
      add_header 'Access-Control-Allow-Origin' '*';
      rewrite           ^/homectrl/(.*) /$1 break;
      proxy_pass http://192.168.0.9:8082;      
    }

    location ~ ^/swimctrl(.*)$ {
      add_header 'Access-Control-Allow-Origin' '*';
      rewrite           ^/swimctrl/(.*) /$1 break;
      proxy_pass http://192.168.0.8:8082;      
    }

And after restarting the nginx server, this time, starting the WASM version of Homeviw will work! Yeeeeppeeee 🥳!!

hmmmm 🤔… it seems that the GET requests are working, but not the post requests, for these I still seem to get a CORS issue. Could be because I need to explicitly enable support for both GET and POST methods ? let's see…

⇒ Using this page as reference: https://serverfault.com/questions/162429/how-do-i-add-access-control-allow-origin-in-nginx

Now updated the nginx rules again:

    location ~ ^/homectrl(.*)$ {
      add_header 'Access-Control-Allow-Origin' '*';
      add_header 'Access-Control-Allow-Methods' 'GET, POST';

      rewrite           ^/homectrl/(.*) /$1 break;
      proxy_pass http://192.168.0.9:8082;      
    }

    location ~ ^/swimctrl(.*)$ {
      add_header 'Access-Control-Allow-Origin' '*';
      add_header 'Access-Control-Allow-Methods' 'GET, POST';

      rewrite           ^/swimctrl/(.*) /$1 break;
      proxy_pass http://192.168.0.8:8082;      
    }

Yet, this doesn't seem to be enough since I still get a CORS error for the POST request. But now I found this page: so, it could be the browser is also sending an OPTIONS request, and we need more stuff setup on the server side, so updating again to this now:

    location ~ ^/homectrl(.*)$ {
      add_header 'Access-Control-Allow-Origin' '*';
      add_header 'Access-Control-Allow-Credentials' 'true';
      add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
      add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type';

      rewrite           ^/homectrl/(.*) /$1 break;
      proxy_pass http://192.168.0.9:8082;      
    }

    location ~ ^/swimctrl(.*)$ {
      add_header 'Access-Control-Allow-Origin' '*';
      add_header 'Access-Control-Allow-Credentials' 'true';
      add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
      add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type';

      rewrite           ^/swimctrl/(.*) /$1 break;
      proxy_pass http://192.168.0.8:8082;      
    }

And still not working… hmmm… 🤔 Now I'm wondering if that' could maybe be related to the python server I use to run the app, which has:

            def send_my_headers(self):
                """Senf my headers"""
                self.send_header("Cross-Origin-Opener-Policy", "same-origin")
                self.send_header("Cross-Origin-Embedder-Policy", "require-corp")

Nope! But now checking the log from the homectrl Flask app, it seems that I'm actually sending a bunch of GET requests to the /handle endpoint here (and that's not supported at all, only POST is available for that endpoint): So this seems to indicate that… I'm not actually sending POST requests in my Request::post_async implementation ? 🤔. Let's check.

Arrfff… Stupid me: I also had to update the CustomResponseHandler to keep sending POST requests instead of GET:

void CustomResponseHandler::retry_request() {
    if (_maxRetries == 0 || _count < _maxRetries) {
        // We may retry the request
        auto* rman = RequestManager::instance();
        logDEBUG("Sending async request...");
        if (_isPost) {
            rman->raw_post_async(_url.c_str(), _jsonData, this, &_headers,
                                 _timeout);
        } else {
            rman->raw_get_async(_url.c_str(), this, &_headers, _timeout);
        }
    } else {
        logWARN("Max retry count reached cannot get request result from {}",
                _url);
        _handler->on_response(nullptr);
    }
}

But of course, this is still not working 🤕… So now I'm starting to think maybe it's directly in the Flask application itself that I should add the header on the response ? Let's see…

* Also, now I get the status code 415 instead of 405, and in the homectrl log, I see that I'm making proper POST requests this time:

2023/07/11 22:30:02 [werkzeug] INFO: 192.168.0.20 - - [11/Jul/2023 22:30:02] "POST /handle HTTP/1.0" 415 -
2023/07/11 22:30:02 [werkzeug] INFO: 192.168.0.20 - - [11/Jul/2023 22:30:02] "POST /handle HTTP/1.0" 415 -
2023/07/11 22:30:02 [werkzeug] INFO: 192.168.0.20 - - [11/Jul/2023 22:30:02] "POST /handle HTTP/1.0" 415 -
2023/07/11 22:30:03 [werkzeug] INFO: 192.168.0.20 - - [11/Jul/2023 22:30:03] "POST /handle HTTP/1.0" 415 -
2023/07/11 22:30:03 [werkzeug] INFO: 192.168.0.20 - - [11/Jul/2023 22:30:03] "POST /handle HTTP/1.0" 415 -
2023/07/11 22:30:03 [werkzeug] INFO: 192.168.0.20 - - [11/Jul/2023 22:30:03] "POST /handle HTTP/1.0" 415 -
2023/07/11 22:30:03 [werkzeug] INFO: 192.168.0.20 - - [11/Jul/2023 22:30:03] "POST /handle HTTP/1.0" 415 -
2023/07/11 22:30:03 [werkzeug] INFO: 192.168.0.20 - - [11/Jul/2023 22:30:03] "POST /handle HTTP/1.0" 415 -

Now checking with curl:

$ curl -I  https://api.nervtech.org/homectrl/state
HTTP/1.1 200 OK
Server: nginx/1.21.6
Date: Tue, 11 Jul 2023 20:49:41 GMT
Content-Type: application/json
Content-Length: 737
Connection: keep-alive
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type

And for the POST command:

$ curl -X POST -H 'Content-Type: application/json' -d '{"device":"Toggle Solar Valve","action":"trigger", "auth":"xxx"}' https://api.nervtech.org/homectrl/handle

⇒ Ok, great, but I cannot see the headers in this case.

But anyway, checking the network tab from firefox I see this:

So it seems I'm not setting the “Content-Type” header correctly here. And this seems confirmed on the header tab:

⇒ let's check how I deal with the headers then.

[A few moments later…] Feeeww, that was really tricky: in the end I need to set the CORS headers both in the flask server and in nginx, but then I have to override the headers on the server side, just don't ask me why 😅, but in the end here is all the dirty mess I got on my nginx config file:

    # cf. https://stackoverflow.com/questions/17423414/nginx-proxy-pass-subpaths-not-redirected
    location ~ ^/homectrl(.*)$ {
      # add_header 'Access-Control-Allow-Origin' 'https://nervtech.local:444';
      # add_header 'Access-Control-Allow-Credentials' 'true';
      # add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
      # add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type';

      # if ($request_method ~* "(GET|POST)") {
      #   add_header "Access-Control-Allow-Origin" "*";
      # }
      # add_header 'Access-Control-Allow-Credentials' 'true';
      # add_header 'Access-Control-Allow-Headers' 'Authorization,Accept,Origin,DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range';
      # add_header 'Access-Control-Allow-Methods' 'GET,POST,OPTIONS,PUT,DELETE,PATCH';


      if ($request_method = 'OPTIONS') {
        # add_header 'Access-Control-Max-Age' 1728000;
        # add_header "Access-Control-Allow-Origin"  "*";
        # add_header "Access-Control-Allow-Methods" "GET, POST, OPTIONS, HEAD";
        # add_header "Access-Control-Allow-Headers" "Authorization, Origin, X-Requested-With, Content-Type, Accept";

        # # add_header 'Content-Type' 'text/plain charset=UTF-8';
        # # add_header 'Content-Length' 0;
        # return 200;

        # cf. https://stackoverflow.com/questions/45986631/how-to-enable-cors-in-nginx-proxy-server
        add_header 'Access-Control-Allow-Origin' '*';
        add_header 'Access-Control-Allow-Credentials' 'true';
        add_header 'Access-Control-Allow-Headers' 'Authorization,Accept,Origin,DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range';
        add_header 'Access-Control-Allow-Methods' 'GET,POST,OPTIONS,PUT,DELETE,PATCH';
        add_header 'Access-Control-Max-Age' 1728000;
        add_header 'Content-Type' 'text/plain charset=UTF-8';
        add_header 'Content-Length' 0;
        return 204;
      }

      rewrite           ^/homectrl/(.*) /$1 break;
      proxy_pass http://192.168.0.9:8082;   
      proxy_hide_header Access-Control-Allow-Origin;   
      add_header 'Access-Control-Allow-Origin' '*' always;
    }

And I'm not even out of the woods yet: now the post request seems to be accepted, but the JSON data it sends is corrupted, so I get a 400 status code 😪. But I think I see why: the fetch.h documentation specify:

⇒ Currently the pointer I'm providing is not valid until the end of the fetch (since this is an async operation):

    // Set the request body
    logDEBUG("Should send post data: '{}'", jsonData);
    attr.requestData = jsonData.c_str();
    attr.requestDataSize = jsonData.size();
/

To fix this, I should also keep track of the json data in a static map somehow before accessing it. So now doing something like this:

    // Store the request handler:
    U64 id = get_next_request_id();
    attr.userData = (void*)id;
    auto& hmap = get_response_handler_map();
    hmap.insert(std::make_pair(id, handler));

    auto& dmap = get_request_data_map();
    dmap.insert(std::make_pair(id, jsonData));

    // Set the request body
    // Assign the pointer towards the referenced string data:
    attr.requestData = dmap[id].c_str();
    attr.requestDataSize = jsonData.size();

    // Run the fetch:
    emscripten_fetch(&attr, url);

Bingo! Now I can finally send my POST requests from the wasm version of homeview, and this request is handled as expected, feeewwwww… that was hard 😳:

Next, we should consider the question of the credentials storage before I can make this app available on my public website: I think the easiest option here would simply be to store the credentials in a local file, and on emscripten side, writing to a file should transparently store the data in the browser indexed DB, right? Let's give this a try.

Here is the initial version of the function I wrote to get the credentials from the user:

void MainWindow::update_credentials() {
    logDEBUG("Should update the credentials here.");

    // Create the dialog window
    QDialog dialog;
    dialog.setWindowTitle("Login");
    dialog.setMinimumWidth(300);

    // Create the form layout
    QFormLayout formLayout(&dialog);

    // Create labels and line edits for username and password
    QLabel usernameLabel("Username:");
    QLineEdit usernameLineEdit;
    QLabel passwordLabel("Password:");
    QLineEdit passwordLineEdit;
    passwordLineEdit.setEchoMode(QLineEdit::Password);

    // Add labels and line edits to the form layout
    formLayout.addRow(&usernameLabel, &usernameLineEdit);
    formLayout.addRow(&passwordLabel, &passwordLineEdit);

    // Create the OK and Cancel buttons
    QPushButton okButton("OK");
    QPushButton cancelButton("Cancel");

    // Connect the OK button's clicked signal to accept the dialog
    QObject::connect(&okButton, &QPushButton::clicked, [&]() {
        auto username = usernameLineEdit.text().toStdString();
        auto password = passwordLineEdit.text().toStdString();
        dialog.accept();
        logDEBUG("Got username: {}, password: {}", username.c_str(),
                 password.c_str());
    });

    // Connect the Cancel button's clicked signal to reject the dialog
    QObject::connect(&cancelButton, &QPushButton::clicked,
                     [&dialog]() { dialog.reject(); });

    // Add the buttons to the form layout
    formLayout.addRow(&okButton, &cancelButton);

    // Show the dialog
    dialog.exec();
}

Now let's save those credentials to file…

    QObject::connect(&okButton, &QPushButton::clicked, [&]() {
        auto username = usernameLineEdit.text().toStdString();
        auto password = passwordLineEdit.text().toStdString();
        dialog.accept();
        logDEBUG("Got username: {}, password: {}", username.c_str(),
                 password.c_str());

        // Now saving the credentials to file:
        auto* state = StateManager::instance()->get_state();
        String auth =
            format_string("%s_%s", username.c_str(), password.c_str());

        state->setString("auth_token", auth);

        auto& app = NervApp::instance();
        String stateFile = get_path(app.get_root_path(), "state.yml");
        write_file(stateFile.c_str(),
                   format_string("auth_token: %s", auth.c_str()));
    });

Arrffff… 😒 Here is something else now: the state is never saved when running with emscripten. I thought it would somehow save that in the browser IndexDB, but no.

Ahhh, okay, now I understand: I just found this page explaining that by default it is the MEMFS system which is used, we need to explicitly request IDBFS, so we need something like that:

    // EM_ASM is a macro to call in-line JavaScript code.
    EM_ASM(
        // Make a directory other than '/'
        FS.mkdir('/homeview');
        // Then mount with IDBFS type
        FS.mount(IDBFS, {}, '/homeview');

        // Then sync
        FS.syncfs(true, function (err) {
            // Error
        });
    );

And of course… that's not working, and I now get this error message 🤣:

I have now updated my linkflags for QT in emscripten to include that library:

macro(get_emscripten_qt_linkflags link_fvar)
  # -s ALLOW_MEMORY_GROWTH=1 -s USE_ZLIB=1 -s USE_HARFBUZZ=1 -s USE_FREETYPE=1
  # -s USE_LIBPNG=1 -s DISABLE_EXCEPTION_CATCHING=1

  set(${link_fvar}
      "${${link_fvar}} -s WASM=1 -O2 --bind -pthread -sPTHREAD_POOL_SIZE=4 -s ASSERTIONS "
  )
  set(${link_fvar}
      "${${link_fvar}} -s FETCH=1 -s WASM_BIGINT=1 -s MODULARIZE=1 -s EXPORT_NAME=createQtAppInstance"
  )
  set(${link_fvar}
      "${${link_fvar}} -s EXPORTED_RUNTIME_METHODS=UTF16ToString,stringToUTF16 -s INITIAL_MEMORY=1GB"
  )
  set(${link_fvar}
      "${${link_fvar}} -s FULL_ES2=1 -s USE_WEBGL2=1 -s NO_EXIT_RUNTIME=1 -s ERROR_ON_UNDEFINED_SYMBOLS=1"
  )
  set(${link_fvar} "${${link_fvar}} -lidbfs.js")
  # Might need to include "--use-preload-plugins" here. 
  # cf. https://emscripten.org/docs/tools_reference/emcc.html?highlight=preload
endmacro()

OK! Now the app is starting again… so tryig to save those credentials again… But still no damned state.yml file on restart 😡! This is really starting to piss me off now. Come on!

aarrffff… I see, reading this page it seems that we have to deal with the synchronization ourself here calling the FS.syncfs() function. So let's give it another try.

Still investigating on this IndexedDB filesystem, now I can see when just starting the app that according to firefox I do have the state.yml file data stored:

So, should be on the loading part of the file that we have an issue ?:

#ifdef __EMSCRIPTEN__
    _storageDir = "/homeview";
#else
    _storageDir = app.get_root_path();
#endif

    String stateFile = get_path(_storageDir, "state.yml");

    // Read the file:
    if (file_exists(stateFile)) {
        logDEBUG("Loading state file...");

        auto* state = StateManager::instance()->get_state();
        state->inherit(AnyMap::read_yaml_file(stateFile.c_str()).get());
    } else {
        logDEBUG("No state file found at {}", stateFile.c_str());
    }

⇒ Let's try to bypass the file_exists() call, and load the file directly! Crap… This will simply produce an exception and fail 😭:

So what am I missing here 🤔?

ohhhhhh 😲 I think I might have another hint now: I added more debug outputs in the initial FS.syncFS() call:

#ifdef __EMSCRIPTEN__
    // EM_ASM is a macro to call in-line JavaScript code.
    EM_ASM(
        // Make a directory other than '/'
        FS.mkdir('/homeview');
        // Then mount with IDBFS type
        FS.mount(IDBFS, {}, '/homeview');

        // Then sync
        // true,
        FS.syncfs(function(err) {
            console.log("Done with initial FS.syncFS()!");
            assert(err == undefined);
            console.log("Checked error.");
        }););
#endif

And now I get those outputs:

⇒ So, it could be here that at the time I try to load the state.yml file, the IDBFS filesystem is not in sync yet! And thus, file is missing!

Let's try to delay the loading of the state file until I realy need it. Okay, what we can see already with this is that the IDBFS load will indeed only be completed after quite some time since we can send our first GET requests and all before that:

But if with that (only trying to load the state.yml file just when I need the credentials), still no luck: no file found 😨 That is just unbelievable… ahhh!!! Naaa, I still had some mixing on the true/false populate value, once this is sorted, FINALLY, this seems to work! 🥳🤣😬😍! My god… this was burning my head so much lol. I think I deserve a good break now 👍.

Okay, back on it now. First, some minor cleaning: I just realized that for each post command I was keeping track of the JSON data provided as input in a string map, but never releasing this data [not good Manu :-)], so fixing that now in the downloadSucceeded and downloadFailed callbacks:

    // Also check if we had JSON data for this request:
    auto& dmap = get_request_data_map();
    auto it2 = dmap.find(id);
    if (it2 != dmap.end()) {
        dmap.erase(it2);
    }
/

Next step will be to investigate how to use brotli to compress our WASM file. But I think I should kep that for another session now ;-) See ya next time!

  • blog/2023/0713_homectrl_wasm_impl.txt
  • Last modified: 2023/07/19 08:45
  • by 127.0.0.1