Homelab, Linux, JS & ABAP (~˘▾˘)~
 

[CAP] Display file content from external resource in Fiori Elements app via an Association

I was having a situation, where I needed to access file content via an association. This led to two problems, one in the backend and one in the frontend.

My data-model.cds looked like this.

entity MainEntity: cuid, managed {
    file            : Association to Files @mandatory  @assert.target;
}

entity Files : cuid, managed {
    content         : LargeBinary @stream  @Core.MediaType: mediaType  @Core.ContentDisposition.Filename: fileName  @Core.ContentDisposition.Type: 'inline'; 
    mediaType       : String      @Core.IsMediaType: true;
    fileName        : String      @mandatory;
    size            : Integer;
}

The file content is actually stored in an external system and is only read when the content is explicitly requested, with a call like this:

### Get file content
GET http://localhost:4004/odata/v4/admin/Files({{ID}})/content
Authorization: Basic user:password

For this kind of scenario, I have found the perfect sample code here: https://github.com/SAP-samples/cloud-cap-samples/blob/main/media/srv/media-service.js
But in my case, I needed to call the file content via an association like this:

### Get file content via association
GET http://localhost:4004/odata/v4/admin/MainEntity({{ID}})/file/content
Authorization: Basic user:password

This did not work, because in this case, we don’t get the required file ID in the Files handler in req.data.ID (find the reason here), which is needed to read the file from the external system. Therefore, I had to implement the following workaround (line 5-8), which checks from which entity we are coming and is fetching the requested file ID from the DB.

    srv.on('READ', Files, async (req, next) => {
        //if file content is requested, return only file as stream
        if (req.context.req.url.includes('content')) {

            // workaround: when File is requested via Association from MainEntity, as the ID is then not provided directly
            if (req.context.req.url.includes('MainEntity')) {
                req.data.ID = await SELECT.one.from(req.subject).columns('ID')
            }

            const file = await SELECT.from(Files, req.data.ID)
            if (!file) return next() // if file not found, just handover to default handler to get 404 response

            try {
                const stream = await getMyStreamFromExternalSystem(req)
                return [{ value: stream }]
            } catch (err) {
                req.error(`Could not read file content`)
            }

        } else return next() // else delegate to next/default handlers without file content
    })

This way, the file content can now be read directly via File and also via MainEntity following the association.

The next challenge was to display this file content in a Fiori Elements app. This works out of the box, if the file content is called directly from the Files entity, means not over an association. But if the file content is coming via an association, it seems like the Fiori Elements framework is creating an incorrect backend call. It tries to call the mediaType from the MainEntity instead of the Files entity, resulting in a failing odata call, which looks like this
/odata/v4/service/MainEntity(key)/mediaType
instead of
/odata/v4/service/MainEntity(key)/file/mediaType.
The only workaround I found was to overwrite the @Core.MediaType annotation coming from the Files entity by setting the mediaType to a hard value in the annotation.yaml of the Fiori Elements App.

annotate service.fileservice@(
    UI.FieldGroup #FileGroup      : {
        $Type: 'UI.FieldGroupType',
        Data : [
            {
                $Type: 'UI.DataField',
                label: 'Main ID',
                Value: ID,
            },
            {
                $Type: 'UI.DataField',
                label: 'File ID',
                Value: file.ID,
            },
            {
                $Type: 'UI.DataField',
                Value: file.content,
            },
            {
                $Type: 'UI.DataField',
                Value: file.mediaType,
            },
            {
                $Type: 'UI.DataField',
                Value: file.fileName,
            },
            {
                $Type: 'UI.DataField',
                Value: file.size,
            },
        ],
    },
    UI.Facets                     : [
        {
            $Type : 'UI.ReferenceFacet',
            ID    : 'GeneratedFacet2',
            Label : 'File Information',
            Target: '@UI.FieldGroup#FileGroup',
        },
    ],
);

// Workaround as currently display file content via an association in Fiori Elements is incorrectly trying to fetch the media type.
// Therefore add a fix value for the media type. Of course, this only works, if you only expect a specific file type.
annotate service.Files with {
  @Core.MediaType : 'application/pdf'
  content
};

In the Fiori Elements App it will now be displayed like this and by clicking on the Context, it will successfully load the file from the backend:

[ABAP] Validate JSON data

While searching on how to validate a given JSON string, I found two options. The first simply returns a Boolean value, the second also returns information about what could be wrong.

DATA(lv_json) = '{'
             &&      '"employee": {'
             &&           |"name" : "Max", |
             &&           |"age"  : 43,    |
             &&      '}'
             && '}'.


" option 1:
DATA(is_valid) = /ui5/cl_json_util=>is_wellformed( lv_json ).

" option 2: 
DATA(lo_reader) = cl_sxml_string_reader=>create( cl_abap_codepage=>convert_to( lv_json ) ).
TRY.
    lo_reader->next_node( ).
    lo_reader->skip_node( ).
  CATCH cx_sxml_parse_error INTO DATA(lx_parse_error).
    WRITE lx_parse_error->get_text( ).
ENDTRY.

[ABAP] Generate a QR-Code

There are many blogs describing how to create a QR-Code in the context of SAPscript or Smartforms (e.g. here, here, here and here). But I was looking for a way to generate a QR-Code and only receive the graphical data stream from it, without the need for any manual steps such a creation via SE73. During my search, I found these two notes, which contained all the information I needed:

  • 2790500: mentions the class CL_RSTX_BARCODE_RENDERER with method QR_CODE
  • 2030263: describes the parameters for a QC-Code creation

Following my little test report:

PARAMETERS p_text TYPE string DEFAULT 'My QR-Code Content'.

DATA: lv_action   TYPE i.
DATA: lv_filename TYPE string.
DATA: lv_fullpath TYPE string.
DATA: lv_path     TYPE string.

TRY.
    cl_rstx_barcode_renderer=>qr_code( EXPORTING i_module_size      = 25                " Size of smallest module (in pixel, max: 32000)
                                                 i_barcode_text     = p_text            " Barcode text
*                                                 i_mode             = 'A'               " Mode ('N', 'A', 'L', 'B', 'K', 'U', '1', '2'; note 2030263)
*                                                 i_error_correction = 'H'               " Error correction ('L', 'M', 'Q', 'H')
*                                                 i_rotation         = 0                 " Rotation (0, 90, 180 or 270)
                                       IMPORTING e_bitmap           = DATA(e_bitmap) ). " Bitmap in BMP format

    DATA(lt_raw_data) = cl_bcs_convert=>xstring_to_solix( e_bitmap ).

    " Save-Dialog
    cl_gui_frontend_services=>file_save_dialog( EXPORTING default_file_name = 'QR-Code'
                                                          default_extension = 'bmp'
                                                          file_filter       = |{ cl_gui_frontend_services=>filetype_all }|
                                                CHANGING  filename          = lv_filename
                                                          path              = lv_path
                                                          fullpath          = lv_fullpath
                                                          user_action       = lv_action ).

    IF lv_action EQ cl_gui_frontend_services=>action_ok.

      " Download file to disk
      cl_gui_frontend_services=>gui_download( EXPORTING filename     = lv_fullpath
                                                        filetype     = 'BIN'
                                                        bin_filesize = xstrlen( e_bitmap )
                                              CHANGING  data_tab     = lt_raw_data ).
    ENDIF.

  CATCH cx_rstx_barcode_renderer INTO DATA(lo_exp).
    MESSAGE lo_exp->get_text( ) TYPE 'I'.
ENDTRY.

[Terminal] Bash script to add leading season and episode numbers by parsing from file names

I had some videos in the format “My Episode #01.mkv” which I wanted to rename to “S01E01 My Episode #01.mkv“. This little script did the job for me:

# Specify the directory containing the files. For current directory use: $(dirname "$0")
directory="/path/to/your/directory"	

# Loop through all .mkv files in the directory
for file in "$directory"/*.{webm,mkv}; do
    # Check if the file exists to avoid errors when no files match
    [ -e "$file" ] || continue
    
    # Extract the base filename (without the directory path)
    filename=$(basename "$file")

    # Use regex to find the episode number (e.g., #01, #02)
    if [[ $filename =~ \#([0-9]+) ]]; then
        episode_number=${BASH_REMATCH[1]}

        # Pad the episode number with a leading zero if it's a single digit
        if [ ${#episode_number} -eq 1 ]; then
            episode_number="0$episode_number"
        fi  
		
        # Construct the new filename
        new_filename="S01E${episode_number} $filename"
        
        # Rename the file
        mv "$file" "$directory/$new_filename"
        echo "Renamed: $filename -> $new_filename"
    fi
done

[SAPUI5] Promisify an oData request

This is discussed for many years and unfortunately will not be implemented in the UI5 framework itself (see here). There are already different blogs describing how to build a wrapper for oData requests (for example here and here).

But with ES2024 it now got super simple to do this:

async function readData(model, entitySet) {
  const [promise, resolve, reject] = Promise.withResolver( )
  model.read(entitySet, {
    success: data => resolve(data),
    error: error => reject(error)
  })
  return promise
}

const user = await readData(oDataModel, "/user")

[JavaScript] ES2023 Array methods

With ES2023 there are some new Array methods that always return a copy of the original array: toReversed, toSorted, toSpliced, with

const people = [
 { name: "Max", age: 19 },
 { name: "Tom", age: 65 },
 { name: "Liz", age: 43 },
]

// Copying counterpart of the reverse() method
const reversedPeople = people.toReversed()
// [{age: 43, name: "Liz"}, {age: 65, name: "Tom"}, {age: 19, name: "Max"}]

// Copying version of the sort()
const sortedPeople = people.toSorted((a,b) => a.age - b.age)
// [{age: 19, name: "Max"}, {age: 43, name: "Liz"}, {age: 65, name: "Tom"}]

// Copying version of the splice() method
const splicedPeople = people.toSpliced(1,1)
// [{age: 19, name: "Max"}, {age: 43, name: "Liz"}]

// Copying version of using the bracket notation to change the value of a given index
const updatedPeople = people.with(0, { name: "Pat", age: 12})
// [{age: 12, name: "Pat"}, {age: 65, name: "Tom"}, {age: 43, name: "Liz"}]

[Home Assistant] Offline detection for Zigbee2MQTT devices using the last_seen attribute

Activate the last_seen attribute via the Zigbee2MQTT interface. Go to Settings → Advanced → Last seen → Choose ISO_8601

Per default, the last seen sensor is disabled for all Home Assistant entities. To enable the last_seen attribute for all devices, add the following lines via VS Code in homeassistant → zigbee2mqtt → configuration.yaml

device_options:
  legacy: false
  homeassistant:
    last_seen:
        enabled_by_default: true

Now you must either restart Home Assistant or activate the entity manually: Go to Settings → Devices & services → Entities and adjust your Filter like this:

  • Integrations: Select “MQTT”
  • Status: Select “Disabled”

Then search for last seen, click on select all (right next to the filter button) and choose Enable selected in the context menu when clicking on the three dots in the top right corner.

Now the last_seen entity values should be visible, and you can use this new entity to detect an offline device. For example, by using this blueprint or by creating a template sensor like it is described here (related YT video). The template sensor can be put somewhere on your dashboard or used in an automation. Following the automation I’m using:

alias: Notify when zigbee device goes offline using last_seen
description: ""
trigger:
  - platform: state
    entity_id:
      - sensor.z2m_offline_devices
    from: null
    to: null
    for:
      hours: 0
      minutes: 10
      seconds: 0
condition: []
action:
  - service: notify.mobile_app_mi_8
    metadata: {}
    data:
      title: |-
        {% if not states('sensor.offline_zigbee_devices') %}
          All Zigbee Devices Online!
        {% else %}
          The following Zigbee Devices are offline:
        {% endif %}
      message: "{{ states('sensor.offline_zigbee_devices') }}"
mode: single

I also recommend excluding the last_seen sensors from the Logbook, because else the Logbook is flooded with changes. To do this, simply add the following lines in your configuration.yaml file:

logbook:
  exclude:
    entity_globs:
      - sensor.*_last_seen

[Home Assistant] Get friendly name of the triggering device in an automation

In an automation, you can retrieve the friendly_name of the triggering device using:

{{ trigger.to_state.attributes.friendly_name }}

Helpful if an automation can be triggered by different devices (e.g. garage door 1 or garage door 2) and you want to send a notification that explicitly names the triggering device:

  - service: notify.ALL_DEVICES
    data:
      title: Garage open!
      message: >-
        {{ trigger.to_state.attributes.friendly_name }} is open