svghmi/widgets_common.ysl2
author Edouard Tisserant <edouard@beremiz.fr>
Thu, 05 Dec 2024 13:56:59 +0100
changeset 4060 d2f5eb3c7d6e
parent 4024 f9c6bbf66eea
permissions -rw-r--r--
py_ext: fix CSV Writer

fix POU logic :
- SAVE is a BOOL
- invocation of py_eval on rising edge of SAVE
- remove save python argument

fix python:
- use no encoding for file open (python2)
- re-use detected dialect if any
- use no "rt+" and truncate since no need to re-sniff dialect for output file
- return "OK" instead of "#SUCCESS", preventing POU logic to ACK result
- support creating new line if writing just after last line
- support appending data on short rows

fix example:
- use a HMI:Button to trigger CSV write instead of HMI:Input +1
- reload CSVs on on each new CSV opened in file browser
- add display of CSV write output
// widgets_common.ysl2

in xsl decl labels(*ptr, name="defs_by_labels") alias call-template {
    with "hmi_element", "$hmi_element";
    with "labels"{text *ptr};
    content;
};

decl optional_labels(*ptr) alias - {
    /* TODO add some per label xslt variable to check if exist */
    labels(*ptr){
        with "mandatory","'no'";
        content;
    }
};

decl warning_labels(*ptr) alias - {
    labels(*ptr){
        with "mandatory","'warn'";
        content;
    }
};

decl _activable(*level) alias - {
    |     activable_sub:{
    const "activity" labels("/active /inactive") {
        with "mandatory"{text *level};
        content;
    }
    value "$activity";
    const "has_activity","string-length($activity)>0";
    |     },
    |     has_activity: «$has_activity»,
};

decl activable() alias - {
    _activable("warn")
};
decl optional_activable() alias - {
    _activable("no")
};

decl activable_labels(*ptr) alias - {
    optional_labels(*ptr) {
        with "subelements","'active inactive'";
        content;
    }
};

in xsl decl widget_desc(%name, match="widget[@type='%name']", mode="widget_desc") alias template {
    type > «@type»
    content;
};

in xsl decl widget_class(%name, *clsname="%nameWidget", match="widget[@type='%name']", mode="widget_class") alias template {
    | class `text **clsname` extends Widget{
    content;
    | }
};

in xsl decl widget_defs(%name, match="widget[@type='%name']", mode="widget_defs") alias template {
    param "hmi_element";
    // all widget potentially has a "disabled" labeled element
    const "disability" optional_labels("/disabled");
    value "$disability";
    const "has_disability","string-length($disability)>0";
    content;
};

in xsl decl widget_page(%name, match="widget[@type='%name']", mode="widget_page") alias template {
    param "page_desc";
    content;
};

decl gen_index_xhtml alias - {
    content;
};

template "svg:*", mode="hmi_widgets" {
    const "widget", "func:widget(@id)";
    const "eltid","@id";
    const "args" foreach "$widget/arg" > "«func:escape_quotes(@value)»"`if "position()!=last()" > ,`
    const "indexes" foreach "$widget/path" {
        if "position()!=last()" > ,
    }

    const "variables" foreach "$widget/path" {
        > [
        choose {
            when "not(@index)" {
                choose {
                    when "not(@type)" {
                        warning > Widget «$widget/@type» id="«$eltid»" : No match for path "«@value»" in HMI tree
                        > undefined
                    }
                    when "@type = 'PAGE_LOCAL'" 
                        > "«@value»"
                    when "@type = 'HMI_LOCAL'" 
                        > hmi_local_index("«@value»")
                    otherwise 
                        error > Internal error while processing widget's non indexed HMI tree path : unknown type
                }
            }
            otherwise {
                > «@index»
            }
        }
        > , {
        if "@min and @max"{
                > minmax:[«@min», «@max»]
                if "@assign"
                    > ,
        }
        if "@assign"
                > assign:"«@assign»"
        > }]
        if "position()!=last()" > ,
    }

    const "freq" choose {
        when "$widget/@freq"
            > "«$widget/@freq»"
        otherwise
            > undefined
    }

    const "enable_expr" choose{
        when "$widget/@enable_expr"
            > true
        otherwise
            > false
    }

    |   "«@id»": new «$widget/@type»Widget ("«@id»",«$freq»,[«$args»],[«$variables»],«$enable_expr»,{
    if "$widget/@enable_expr" {

    |       enable_assignments: [],
    |       compute_enable: function(value, oldval, varnum) {
    |         let result = false;
    |         do {
        foreach "$widget/path" {
            const "varid","generate-id()";
            const "varnum","position()-1";
            if "@assign" foreach "$widget/path[@assign]" if "$varid = generate-id()" {
    |           if(varnum == «$varnum») this.enable_assignments[«position()-1»] = value;
    |           let «@assign» = this.enable_assignments[«position()-1»];
    |           if(«@assign» == undefined) break;
            }
        }
    |           result = «$widget/@enable_expr»;
    |         } while(0);
    |         this.enable(result);
    |       },
    }
    apply "$widget", mode="widget_defs" with "hmi_element",".";
    |   })`if "position()!=last()" > ,`
}

emit "preamble:local-variable-indexes" {
    ||

    let hmi_locals = {};
    var last_remote_index = hmitree_types.length - 1;
    var next_available_index = hmitree_types.length;
    let cookies = new Map(document.cookie.split("; ").map(s=>s.split("=")));

    const local_defaults = {
    ||
    foreach "$parsed_widgets/widget[starts-with(@type,'VarInit')]"{
        if "count(path) != 1" error > VarInit «@id» must have only one variable given.
        if "path/@type != 'PAGE_LOCAL' and path/@type != 'HMI_LOCAL'" error > VarInit «@id» only applies to HMI variable.
        >     "«path/@value»":
        choose {
            when "@type = 'VarInitPersistent'" > cookies.has("«path/@value»")?cookies.get("«path/@value»"):«arg[1]/@value»
            otherwise > «arg[1]/@value»
        }
        > \n
        if "position()!=last()" > ,
    }
    ||
    };

    const persistent_locals = new Set([
    ||
    foreach "$parsed_widgets/widget[@type='VarInitPersistent']"{
    |    "«path/@value»"`if "position()!=last()" > ,`
    }
    ||
    ]);
    var persistent_indexes = new Map();
    var cache = hmitree_types.map(_ignored => undefined);

    function page_local_index(varname, pagename){
        let pagevars = hmi_locals[pagename];
        let new_index;
        if(pagevars == undefined){
            new_index = next_available_index++;
            hmi_locals[pagename] = {[varname]:new_index};
        } else {
            let result = pagevars[varname];
            if(result != undefined) {
                return result;
            }

            new_index = next_available_index++;
            pagevars[varname] = new_index;
        }
        let defaultval = local_defaults[varname];
        if(defaultval != undefined) {
            cache[new_index] = defaultval; 
            if(persistent_locals.has(varname))
                persistent_indexes.set(new_index, varname);
        }
        return new_index;
    }

    function hmi_local_index(varname){
        return page_local_index(varname, "HMI_LOCAL");
    }
    ||
}

emit "preamble:widget-base-class" {
    ||
    var pending_widget_animates = [];

    function _hide(elt, placeholder){
        if(elt.parentNode != null)
            placeholder.parentNode.removeChild(elt);
    }
    function _show(elt, placeholder){
        placeholder.parentNode.insertBefore(elt, placeholder);
    }

    function set_activity_state(eltsub, state){
        if(eltsub.active_elt != undefined){
            if(eltsub.active_elt_placeholder == undefined){
                eltsub.active_elt_placeholder = document.createComment("");
                eltsub.active_elt.parentNode.insertBefore(eltsub.active_elt_placeholder, eltsub.active_elt);
            }
            (state?_show:_hide)(eltsub.active_elt, eltsub.active_elt_placeholder);
        }
        if(eltsub.inactive_elt != undefined){
            if(eltsub.inactive_elt_placeholder == undefined){
                eltsub.inactive_elt_placeholder = document.createComment("");
                eltsub.inactive_elt.parentNode.insertBefore(eltsub.inactive_elt_placeholder, eltsub.inactive_elt);
            }
            ((state || state==undefined)?_hide:_show)(eltsub.inactive_elt, eltsub.inactive_elt_placeholder);
        }
    }

    class Widget {
        offset = 0;
        frequency = 10; /* FIXME arbitrary default max freq. Obtain from config ? */
        unsubscribable = false;
        pending_animate = false;

        constructor(elt_id, freq, args, variables, enable_expr, members){
            this.element_id = elt_id;
            this.element = id(elt_id);
            this.args = args;
            
            [this.indexes, this.variables_options] = (variables.length>0) ? zip(...variables) : [[],[]];
            this.indexes_length = this.indexes.length;

            this.enable_expr = enable_expr;
            this.enable_state = true;
            this.enable_displayed_state = true;
            this.enabled_elts = [];

            Object.keys(members).forEach(prop => this[prop]=members[prop]);
            this.lastapply = this.indexes.map(() => undefined);
            this.inhibit = this.indexes.map(() => undefined);
            this.pending = this.indexes.map(() => undefined);
            this.bound_uninhibit = this.uninhibit.bind(this);

            this.lastdispatch = this.indexes.map(() => undefined);
            this.deafen = this.indexes.map(() => undefined);
            this.incoming = this.indexes.map(() => undefined);
            this.bound_undeafen = this.undeafen.bind(this);

            this.forced_frequency = freq;
            this.clip = true;
        }

        do_init(){
            let forced = this.forced_frequency;
            if(forced !== undefined){
                /*
                once every 10 seconds : 10s
                once per minute : 1m
                once per hour : 1h
                once per day : 1d
                */
                let unit = forced.slice(-1);
                let factor = {
                    "s":1,
                    "m":60,
                    "h":3600,
                    "d":86400}[unit];

                this.frequency = factor ? 1/(factor * Number(forced.slice(0,-1)))
                                          : Number(forced);
            }

            let init = this.init;
            if(typeof(init) == "function"){
                try {
                    init.call(this);
                } catch(err) {
                    console.log(err);
                }
            }

            if(this.enable_expr){
                this.enable_state = false;
                this.enable_displayed_state = false;
                for(let child of Array.from(this.element.children)){
                    let label = child.getAttribute("inkscape:label");
                    if(label!="disabled"){
                        this.enabled_elts.push(child);
                        this.element.removeChild(child);
                    }
                }
            }
        }

        unsub(){
            /* remove subsribers */
            for(let i = 0; i < this.indexes_length; i++) {
                /* flush updates pending because of inhibition */
                let inhibition = this.inhibit[i];
                if(inhibition != undefined){
                    clearTimeout(inhibition);
                    this.lastapply[i] = undefined;
                    this.uninhibit(i);
                }
                let deafened = this.deafen[i];
                if(deafened != undefined){
                    clearTimeout(deafened);
                    this.lastdispatch[i] = undefined;
                    this.undeafen(i);
                }
                let index = this.get_variable_index(i);
                subscribers(index).delete(this);
            }
            this.offset = 0;
            this.relativeness = undefined;
        }

        sub(new_offset, relativeness, container_id){
            this.offset = new_offset;
            this.relativeness = relativeness;
            this.container_id = container_id ;
            /* add this's subsribers */
            for(let i = 0; i < this.indexes_length; i++) {
                let index = this.get_variable_index(i);
                if(index == undefined) continue;
                subscribers(index).add(this);
            }
            this.apply_cache(); 
        }

        apply_cache() {
            for(let i = 0; i < this.indexes_length; i++) {
                /* dispatch current cache in newly opened page widgets */
                let realindex = this.get_variable_index(i);
                if(realindex == undefined) continue;
                let cached_val = cache[realindex];
                if(cached_val != undefined)
                    this.feed_data_for_dispatch(cached_val, cached_val, i);
            }
        }

        get_variable_index(varnum) {
            let index = this.indexes[varnum];
            if(typeof(index) == "string"){
                index = page_local_index(index, this.container_id);
            } else {
                if(this.relativeness[varnum]){
                    index += this.offset;
                }
            }
            return index;
        }

        overshot(new_val, max) {
        }

        undershot(new_val, min) {
        }

        clip_min_max(index, new_val) {
            let minmax = this.variables_options[index].minmax;
            if(minmax !== undefined && typeof new_val == "number") {
                let [min,max] = minmax;
                if(new_val < min){
                    this.undershot(new_val, min);
                    return min;
                }
                if(new_val > max){
                    this.overshot(new_val, max);
                    return max;
                }
            }
            return new_val;
        }

        change_hmi_value(index, opstr) {
            let realindex = this.get_variable_index(index);
            if(realindex == undefined) return undefined;
            let old_val = cache[realindex];
            let new_val = eval_operation_string(old_val, opstr);
            if(this.clip)
                new_val = this.clip_min_max(index, new_val);
            return apply_hmi_value(realindex, new_val);
        }

        _apply_hmi_value(index, new_val) {
            let realindex = this.get_variable_index(index);
            if(realindex == undefined) return undefined;
            if(this.clip)
                new_val = this.clip_min_max(index, new_val);
            return apply_hmi_value(realindex, new_val);
        }

        uninhibit(index){
            this.inhibit[index] = undefined;
            let new_val = this.pending[index];
            this.pending[index] = undefined;
            return this.apply_hmi_value(index, new_val);
        }

        apply_hmi_value(index, new_val) {
            if(this.inhibit[index] == undefined){
                let now = Date.now();
                let min_interval = 1000/this.frequency;
                let lastapply = this.lastapply[index];
                if(lastapply == undefined || now > lastapply + min_interval){
                    this.lastapply[index] = now;
                    return this._apply_hmi_value(index, new_val);
                }
                else {
                    let elapsed = now - lastapply;
                    this.pending[index] = new_val;
                    this.inhibit[index] = setTimeout(this.bound_uninhibit, min_interval - elapsed, index);
                }
            }
            else {
                this.pending[index] = new_val;
                return new_val;
            }
        }

        new_hmi_value(index, value, oldval) {
            // TODO avoid searching, store index at sub()
            for(let i = 0; i < this.indexes_length; i++) {
                let refindex = this.get_variable_index(i);
                if(refindex == undefined) continue;

                if(index == refindex) {
                    this.feed_data_for_dispatch(value, oldval, i);
                    break;
                }
            }
        }

        undeafen(index){
            this.deafen[index] = undefined;
            let [new_val, old_val] = this.incoming[index];
            this.incoming[index] = undefined;
            this.lastdispatch[index] = Date.now();
            this.do_dispatch(new_val, old_val, index);
        }

        enable(enabled){
            if(this.enable_state != enabled){
                this.enable_state = enabled;
                this.request_animate();
            }
        }

        animate_enable(){
            if(this.enable_state && !this.enable_displayed_state){
                //show widget
                for(let child of this.enabled_elts){
                    this.element.appendChild(child);
                }

                //hide disabled content
                if(this.disabled_elt && this.disabled_elt.parentNode != null)
                    this.element.removeChild(this.disabled_elt);

                this.enable_displayed_state = true;

            }else if(!this.enable_state && this.enable_displayed_state){

                //hide widget
                for(let child of this.enabled_elts){
                    if(child.parentNode != null)
                        this.element.removeChild(child);
                }

                //show disabled content
                if(this.disabled_elt)
                    this.element.appendChild(this.disabled_elt);

                this.enable_displayed_state = false;

                // once disabled activity display is lost
                this.activity_displayed_state = undefined;
            }
        }

        feed_data_for_dispatch(value, oldval, varnum) {
            if(this.dispatch || this.enable_expr){
                if(this.deafen[varnum] == undefined){
                    let now = Date.now();
                    let min_interval = 1000/this.frequency;
                    let lastdispatch = this.lastdispatch[varnum];
                    if(lastdispatch == undefined || now > lastdispatch + min_interval){
                        this.lastdispatch[varnum] = now;
                        this.do_dispatch(value, oldval, varnum)
                    }
                    else {
                        let elapsed = now - lastdispatch;
                        this.incoming[varnum] = [value, oldval];
                        this.deafen[varnum] = setTimeout(this.bound_undeafen, min_interval - elapsed, varnum);
                    }
                }
                else {
                    this.incoming[varnum] = [value, oldval];
                }
            }
        }

        do_dispatch(value, oldval, varnum) {
            if(this.dispatch) try {
                this.dispatch(value, oldval, varnum);
            } catch(err) {
                console.log(err);
            }
            if(this.enable_expr) try {
                this.compute_enable(value, oldval, varnum);
            } catch(err) {
                console.log(err);
            }
        }

        _animate(){
            if(this.enable_expr)
                this.animate_enable();
            // inhibit widget animation when disabled
            if(!this.enable_expr || this.enable_state){
                if(this.has_activity)
                    this.animate_activity();
                if(this.animate != undefined)
                    this.animate();
            }
            this.pending_animate = false;
        }

        request_animate(){
            if(!this.pending_animate){
                pending_widget_animates.push(this);
                this.pending_animate = true;
                requestHMIAnimation();
            }
        }

        animate_activity(){
            if(this.activity_displayed_state != this.activity_state){
                set_activity_state(this.activable_sub, this.activity_state);
                this.activity_displayed_state = this.activity_state;
            }
        }
    }
    ||
}

const "excluded_types", "str:split('Page VarInit VarInitPersistent')";

// Key to filter unique types
key "TypesKey", "widget", "@type";

emit "declarations:hmi-classes" {
    const "used_widget_types", """$parsed_widgets/widget[
                                    generate-id() = generate-id(key('TypesKey', @type)) and 
                                    not(@type = $excluded_types)]""";
    apply "$used_widget_types", mode="widget_class";

}

template "widget", mode="widget_class" {
    ||
    class «@type»Widget extends Widget{
        /* empty class, as «@type» widget didn't provide any */
    }
    ||
    warning > «@type» widget is used in SVG but widget type is not declared
}

const "included_ids","$parsed_widgets/widget[not(@type = $excluded_types) and not(@id = $discardable_elements/@id)]/@id";
const "page_ids","$parsed_widgets/widget[@type = 'Page']/@id";
const "hmi_widgets","$hmi_elements[@id = $included_ids]";
const "page_widgets","$hmi_elements[@id = $page_ids]";
const "result_widgets","$result_svg_ns//*[@id = $hmi_widgets/@id]";

emit "declarations:hmi-elements" {
    | var hmi_widgets = {
    apply "$hmi_widgets | $page_widgets", mode="hmi_widgets";
    | }
    |
}

function "defs_by_labels" {
    param "labels","''";
    param "mandatory","'yes'";
    param "subelements","/..";
    param "hmi_element";
    const "widget_type","@type";
    const "widget_id","@id";
    foreach "str:split($labels)" {
        const "absolute", "starts-with(., '/')";
        const "name","substring(.,number($absolute)+1)";
        const "widget","$result_widgets[@id = $hmi_element/@id]";
        const "elt","($widget//*[not($absolute) and @inkscape:label=$name] | $widget/*[$absolute and @inkscape:label=$name])[1]";
        choose {
            when "not($elt/@id)" {
                if "$mandatory!='no'" {
                    const "errmsg" > «$widget_type» widget (id=«$widget_id») must have a «$name» element
                    choose { 
                        when "$mandatory='yes'" {
                            error > «$errmsg»
                        }
                        otherwise {
                            warning > «$errmsg»
                        }
                    }
                }
                // otherwise produce nothing
            }
            otherwise {
                |     «$name»_elt: id("«$elt/@id»"),
                if "$subelements" {
                |     «$name»_sub: {
                    foreach "str:split($subelements)" {
                        const "subname",".";
                        const "subelt","$elt/*[@inkscape:label=$subname][1]";
                        choose {
                            when "not($subelt/@id)" {
                                if "$mandatory!='no'" {
                                    const "errmsg" > «$widget_type» widget (id=«$widget_id») must have a «$name»/«$subname» element
                                    choose { 
                                        when "$mandatory='yes'" {
                                            error > «$errmsg»
                                        }
                                        otherwise {
                                            warning > «$errmsg»
                                        }
                                    }
                                }
                |         /* missing «$name»/«$subname» element */
                            }
                            otherwise {
                |         "«$subname»_elt": id("«$subelt/@id»")`if "position()!=last()" > ,`
                            }
                        }
                    }
                |     },
                }
            }
        }
    }
}

def "func:escape_quotes" {
    param "txt";
    // have to use a python string to enter escaped quote
    // const "frstln", "string-length($frst)";
    choose {
        when !"contains($txt,'\"')"! {
            result !"concat(substring-before($txt,'\"'),'\\\"',func:escape_quotes(substring-after($txt,'\"')))"!;
        }
        otherwise {
            result "$txt";
        }
    }
}