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

Export device pictures #10808

Closed
dmohns opened this issue May 16, 2024 · 11 comments
Closed

Export device pictures #10808

dmohns opened this issue May 16, 2024 · 11 comments
Assignees
Labels
question generic question

Comments

@dmohns
Copy link

dmohns commented May 16, 2024

Component

  • UI

Description

In my use-case my device a picture telemetry. The contains a base64 encoded image, which is used for example in the Photo Camera Input widget.

I would like to give my users the possibility to export the image of a device via some kind of export. However, using the built-in export button I'm only able to export the base64 encoded string of the picture.

Is there a way to trigger a browser download of a device's image as PNG, JPEG, etc.. via some widget? For

  • A single device
  • Multiple devices at once

?

Alternatives considered

I check the Thingsboard Widget collection https://github.com/devaskim/awesome-thingsboard if there already exists a custom widget for this. But couldn't find one. I'm not an export in Widget development, but I wasn't sure if it's even technically possible to implement one.

Environment

  • OS: MacOS 14.4.1
  • ThingsBoard: 3.6.3
  • Browser: Chrome

Disclaimer

We appreciate your contribution whether it is a bug report, feature request, or pull request with improvement (hopefully). Please comply with the Community ethics policy, and do not expect us to answer your requests immediately. Also, do not treat GitHub issues as a support channel.

@dmohns dmohns added the question generic question label May 16, 2024
@devaskim
Copy link
Contributor

You can create custom action and create your image file on the fly. For details see this SO thread

@dmohns
Copy link
Author

dmohns commented May 17, 2024

Nice, thanks for the hint! For reference I got it to work with the following custom action

let $injector = widgetContext.$scope.$injector;
let deviceService = $injector.get(widgetContext.servicesMap.get('deviceService'));
let attributeService = $injector.get(widgetContext.servicesMap.get('attributeService'));


// generates a download link and programmatically clicks it
function downloadBase64Picture(picture, fileName) {
    // Extract the mime type from the base64 string and set extension
    const mimeType = picture.match(/^data:(.*);base64,/)[1];
    const fileExtension = mimeType.split('/')[1];
      
    const link = document.createElement('a');
    link.href = picture;
    link.download = `${fileName}.${fileExtension}`;
      
    // Append the link to the body (required for Firefox)
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
}


function getDevicePicture() {
    deviceService.getDevice(entityId.id).subscribe(
        function (device) {
            attributeService.getEntityAttributes(
                device.id, 
                'SERVER_SCOPE',
                ['picture']
            ).subscribe(
               function (attributes) {
                    for (let i = 0; i < attributes.length; i++) {
                        downloadBase64Picture(attributes[i].value, entityName)
                    }
               } 
            );
        }
    );    
}

getDevicePicture();

This works well for Widgets that select a single entity.

Any idea how this could be adapted to also work for Widgets that select multiple entities?

@devaskim
Copy link
Contributor

@dmohns Thanks for sharing the solution, will add it to awesome-thingsboard

@dmohns
Copy link
Author

dmohns commented May 17, 2024

@dmohns Thanks for sharing the solution, will add it to awesome-thingsboard

Nice! Thank you. Please note, that in my example picture attribute is stored as a SERVER_SCOPE attribute. Can't remember if this was the default behaviour, or something I customised in my setup.

@devaskim
Copy link
Contributor

Any idea how this could be adapted to also work for Widgets that select multiple entities?

What widget do you need? For example, if you use Entities Table widget, there are rowClick, doubleClick, actionCell and upcoming cellClick (in 3.7), where you can do the same as you suggested above

@dmohns
Copy link
Author

dmohns commented May 17, 2024

Any idea how this could be adapted to also work for Widgets that select multiple entities?

What widget do you need? For example, if you use Entities Table widget, there are rowClick, doubleClick, actionCell and upcoming cellClick (in 3.7), where you can do the same as you suggested above

In fact, I have two use cases.

  1. For the simple use-case I have a single Device Dashboard from which I want to be able to extract the devices pictures. This works already with the widget action above.
  2. Additionally, I have an asset Dashboard which shows multiple devices that are assigned to that asset. This is implemented as an Entity Alias that "resolves as multiple entities". I want to give users the possibility to download all of the related device pictures at once.

On this dashboard, I do show entities in an entity table widget (and want to add the download button there). Adding the code above only downloads the first image in the list.

This is because entityName and entityId.id seem to only link the first image.

My question is: How can I access the entire list of entityNames and Id's for all entities in the Widget programatically?

@devaskim
Copy link
Contributor

How can I access the entire list of entityNames and Id's for all entities in the Widget programatically?

widgetContext.datasources - entire list of entities, with console.log(widgetContext.datasources) you can explore its structure

@dmohns
Copy link
Author

dmohns commented May 17, 2024

Nice, thanks again.

After fiddling a bit with the asynchronous nature of some of the involved functions: Here is an updated version of the action that downloads all pictures as a single ZIP file. Works for single entity or multiple entities.

Type: Custom action (with HTML template)
Resources: https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js (Is module False)
JS:

let $injector = widgetContext.$scope.$injector;
let deviceService = $injector.get(widgetContext.servicesMap.get('deviceService'));
let attributeService = $injector.get(widgetContext.servicesMap.get('attributeService'));


// generates a ZIP file of pictures, a download link and programmatically clicks it
async function downloadPictureZip(pictures, zipFilename) {
     // JSZip magic
    const zip = new JSZip();
    
    // Loop through each picture in the array and add it to the zip file
    pictures.forEach(picture => {
        // Extract the file extension from the base64 string
        const mimeType = picture.picture_base64.match(/^data:(.*);base64,/)[1];
        const fileExtension = mimeType.split('/')[1];
    
        // Add the base64 image to the zip file with the specified name and extension
        zip.file(`${picture.name}.${fileExtension}`, picture.picture_base64.split(',')[1], { base64: true });
    });
    const zipBlob = await zip.generateAsync({ type: 'blob' });
    
    // Create a URL for the blob and create a link element
    const url = URL.createObjectURL(zipBlob);
    const link = document.createElement('a');
    link.href = url;
    link.download = zipFilename;
  
    // Append the link to the body (required for Firefox) 
    // programmatically click the link and finally clean up
    document.body.appendChild(link);
    link.click();
    URL.revokeObjectURL(url);
    document.body.removeChild(link);
}


function getDevicePictures() {
    const picturePromises = widgetContext.datasources.map(datasource => {
        return new Promise((resolve, reject) => {
            deviceService.getDevice(datasource.entityId).subscribe(
                device => {
                    attributeService.getEntityAttributes(
                        device.id,
                        'SERVER_SCOPE',
                        ['picture']
                    ).subscribe(
                        attributes => {
                            const pictures = attributes.map(attribute => ({
                                name: datasource.entityName,
                                picture_base64: attribute.value
                            }));
                            resolve(pictures);
                        },
                        error => reject(error)
                    );
                },
                error => reject(error)
            );
        });
    });

    return Promise.all(picturePromises).then(results => results.flat());
}

// Asynchronous operation to fetch pictures and add them to the Zip file
getDevicePictures()
    .then(pictures => {

        return downloadPictureZip(pictures, 'all_pictures.zip');
    })
    .catch(error => {
        console.error('Error fetching device pictures:', error);
    });

@devaskim
Copy link
Contributor

devaskim commented May 17, 2024

Several notes:

  1. deviceService.getDevice(datasource.entity.id.id) - you don't use the result of this API call, so you don't need it
  2. Just call widgetContext.attributeService instead of injector-based approach.
  3. Shorter form of datasource.entity.id.id is datasource.entityId

@dmohns
Copy link
Author

dmohns commented May 21, 2024

Thanks for all the further advice!

  1. deviceService.getDevice(datasource.entity.id.id) - you don't use the result of this API call, so you don't need it

Good catch. I changed the code around a little bit, such that I am now re-using the result of getDevice as input for getEntityAttributes. I understand this is not strictly needed, but I find it a bit more general, and might help users that want to customise the logic.

  1. Shorter form of datasource.entity.id.id is datasource.entityId

👍

  1. Just call widgetContext.attributeService instead of injector-based approach.

I don't fully understand this (and to an extend, not what even an injector is either 😇 ). I'm not sure how much of an improvement this is and if it's worth the time to further change the code.


Too not have too many code blocks floating around in this thread I will update previously shared snippets with the suggestions.


PS: This main goal of this issue was resolved, I think we can close it. WDYT?

@devaskim
Copy link
Contributor

WDYT?

It is up to you, to close or not. In general, you made awesome contribution by sharing the solution.

I'm not sure how much of an improvement this is and if it's worth the time to further change the code.

Frankly speaking I just don't like TB's injector approach of getting access to one of available API services. In my opinion, the shorter form (widgetContext.someApiService) should be a single way especially if the autocomplete feature is up to date.

@epalchenko epalchenko self-assigned this May 25, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
question generic question
Projects
None yet
Development

No branches or pull requests

4 participants