1#!/usr/bin/env python3
2
3"""Report all symbols and docs in decoders of rtl_433 as json."""
4
5# from ../include/rtl_433_devices.h
6#     DECL(silvercrest) \
7#
8# static char *output_fields_EG53MA4[] = {
9#         "model",
10#         "type",
11#         "id",
12#         "flags",
13#         "pressure_kPa",
14#         "temperature_F",
15#         "mic",
16#         NULL,
17# };
18#
19# r_device schraeder = {
20#         .name        = "Schrader TPMS",
21#         .modulation  = OOK_PULSE_MANCHESTER_ZEROBIT,
22#         .short_width = 120,
23#         .long_width  = 0,
24#         .sync_width  = 0,
25#         .gap_limit   = 0,
26#         .reset_limit = 480,
27#         .decode_fn   = &schraeder_callback,
28#         .disabled    = 0,
29#         .fields      = output_fields,
30# };
31
32import sys
33import os
34from os import listdir
35from os.path import join, isfile, isdir, getsize
36import fnmatch
37import json
38import datetime
39import re
40
41errout = sys.stderr
42haserr = False
43
44
45def log(s):
46    print(s, file=errout)
47
48
49def err(s):
50    global haserr
51    haserr = True
52    print(s, file=errout)
53
54
55def process_protocols(path):
56    """Extract protocol numbers from a decl file."""
57    protocols = []
58
59    with open(path, encoding='utf-8', errors='replace') as f:
60        for line in f.readlines():
61            #    DECL(prologue)
62            m = re.match(r'\s*DECL\s*\(\s*([^\)]*)\s*\)', line)
63            if m:
64                pName = m.group(1)
65                protocols.append(pName)
66
67    return protocols
68
69
70def update_links(links, rName, name, i, key, param):
71    if not rName:
72        err(f"::error file={name},line={i}::Key without r_device ({key}: {param})")
73    links[rName].update({key: param})
74
75
76def process_source(path, name):
77    """Extract symbols and documentation from a decoder file."""
78    links = {}
79    links[name] = {"src": name, "line": 1, "type": "file"}
80    with open(join(path, name), encoding='utf-8', errors='replace') as f:
81        fName = None
82        fLine = None
83        rName = None
84        captureDoc = False
85        fileDoc = False
86        dLine = None
87        dSee = None
88        doc = None
89        for i, line in enumerate(f):
90            # look for documentation comments:
91            # /** @file ... */
92            # /** @fn ... */
93            m = re.match(r'\s*\*/', line)
94            if captureDoc and m:
95                captureDoc = False
96                if fileDoc:
97                    links[name].update({"doc_line": dLine, "doc": doc})
98                    fileDoc = False
99                    doc = None
100                if fName:
101                    if fName not in links:
102                        links[fName] = {"src": name, "type": "func"}
103                    links[fName].update({"doc_line": dLine, "doc": doc})
104                    doc = None
105                    fName = None
106                continue
107            if captureDoc:
108                doc += line
109                m = re.match(r'\s*\@sa\s+(.*?)\(\)\s*', line)
110                if m:
111                    dSee = m.group(1)
112                continue
113            # inline link /** @sa func() */
114            m = re.match(r'\s*/\*\*\s*\@sa\s+(.*?)\(\)\s*\*/', line)
115            if m:
116                dLine = i + 1
117                dSee = m.group(1)
118                continue
119            # inline /** ... */
120            m = re.match(r'\s*/\*\*\s*(.*?)\s*\*/', line)
121            if m:
122                dLine = i + 1
123                doc = m.group(1)
124                continue
125            # copyright /** @file ... */
126            m = re.match(r'\s*/\*\*\s*@file', line)
127            if m:
128                captureDoc = True
129                fileDoc = True
130                dLine = i + 1
131                doc = ''
132                continue
133            # /** @fn ... */
134            m = re.match(
135                r'\s*/\*\*\s*@fn\s+(?:\s*static\s*)?(?:\s*int\s*)?([a-zA-Z0-9_]+)\(\s*r_device\s+\*\s*[a-z]+\s*,\s*bitbuffer_t\s+\*\s*[a-z]+', line)
136            if m:
137                fName = m.group(1)
138                captureDoc = True
139                dLine = i + 1
140                doc = ''
141                continue
142            m = re.match(r'\s*/\*\*', line)
143            if m:
144                captureDoc = True
145                dLine = i + 1
146                doc = ''
147                continue
148
149            # look for r_device with decode_fn
150            m = re.match(r'\s*r_device\s+([^\*]*?)\s*=', line)
151            if m:
152                rName = m.group(1)
153                if rName in links:
154                    err(f"::error file={name},line={i}::Duplicate r_device ({rName})")
155                links[rName] = {"src": name, "line": i + 1, "type": "r_device"}
156                if dSee:
157                    links[rName].update({"doc_line": dLine, "doc_see": dSee})
158                    dSee = None
159                if doc:
160                    links[rName].update({"doc_line": dLine, "doc": doc})
161                    doc = None
162                continue
163            # .name        = "The Name",
164            m = re.match(r'\s*\.name\s*=\s*"([^"]*)', line)
165            if m:
166                update_links(links, rName, name, i, 'name', m.group(1))
167                continue
168            # .modulation  = OOK_PULSE_MANCHESTER_ZEROBIT,
169            m = re.match(r'\s*\.modulation\s*=\s*([^,\s]*)', line)
170            if m:
171                update_links(links, rName, name, i, 'modulation', m.group(1))
172                continue
173            # .short_width = 120,
174            m = re.match(r'\s*\.short_width\s*=\s*([^,\s]*)', line)
175            if m:
176                update_links(links, rName, name, i, 'short_width', m.group(1))
177                continue
178            # .long_width  = 0,
179            m = re.match(r'\s*\.long_width\s*=\s*([^,\s]*)', line)
180            if m:
181                update_links(links, rName, name, i, 'long_width', m.group(1))
182                continue
183            # .sync_width  = 0,
184            m = re.match(r'\s*\.sync_width\s*=\s*([^,\s]*)', line)
185            if m:
186                update_links(links, rName, name, i, 'sync_width', m.group(1))
187                continue
188            # .gap_limit   = 0,
189            m = re.match(r'\s*\.gap_limit\s*=\s*([^,\s]*)', line)
190            if m:
191                update_links(links, rName, name, i, 'gap_limit', m.group(1))
192                continue
193            # .reset_limit = 480,
194            m = re.match(r'\s*\.reset_limit\s*=\s*([^,\s]*)', line)
195            if m:
196                update_links(links, rName, name, i, 'reset_limit', m.group(1))
197                continue
198            # .decode_fn   = &the_callback,
199            m = re.match(r'\s*\.decode_fn\s*=\s*&([^,\s]*)', line)
200            if m:
201                update_links(links, rName, name, i, 'decode_fn', m.group(1))
202                continue
203            # .disabled    = 0,
204            m = re.match(r'\s*\.disabled\s*=\s*([^,\s]*)', line)
205            if m:
206                update_links(links, rName, name, i, 'disabled', m.group(1))
207                continue
208
209            # static int foo_callback(r_device *decoder, bitbuffer_t *bitbuffer)
210            # static int foo_callback(r_device *decoder, bitbuffer_t *bitbuffer, ...
211            # static int
212            # foo_callback(r_device *decoder, bitbuffer_t *bitbuffer)
213            m = re.match(
214                r'(?:\s*static\s*int\s*)?([a-zA-Z0-9_]+)\(\s*r_device\s+\*\s*[a-z]+\s*,\s*bitbuffer_t\s+\*\s*[a-z]+', line)
215            if m:
216                # print(m.group(1))
217                fName = m.group(1)
218                fLine = i + 1
219                if fName not in links:
220                    links[fName] = {}
221                links[fName].update({"src": name, "line": fLine, "type": "func"})
222                if dSee:
223                    links[fName].update({"doc_line": dLine, "doc_see": dSee})
224                    dSee = None
225                if doc:
226                    links[fName].update({"doc_line": dLine, "doc": doc})
227                    doc = None
228                continue
229            # "model", "", DATA_STRING, "Schrader",
230            m = re.match(r'\s*"model"\s*,.*DATA_STRING', line)
231            if m:
232                prefix = m.group(0)
233                s = line[len(prefix):]
234                models = re.findall(r'"([^"]+)"', s)
235                if len(models) == 0:
236                    err(f"::error file={name},line={i + 1}::No models")
237                if not fName:
238                    err(f"::error file={name},line={i + 1}::No func")
239                for model in models:
240                    if model in links and links[model]["func"] != fName:
241                        log(f"::notice file={name},line={i + 1}::Reused model")
242                    elif model in links:
243                        log(f"::notice file={name},line={i + 1}::Duplicate model")
244                    links[model] = {"src": name, "line": i + 1, "type": "model", "func": fName}
245
246    if captureDoc:
247        err(f"::error file={name},line={dLine}::Unclosed doc comment")
248    if dSee:
249        err(f"::error file={name},line={dLine}::Unattached doc sa")
250    if doc:
251        err(f"::error file={name},line={dLine}::Unattached doc comment")
252
253    return links
254
255
256def check_symbols(symbols):
257    """Check link integrity."""
258    models_by_func = {}
259    for f in symbols:
260        d = symbols[f]
261
262        if f == "protocols":
263            continue
264
265        if d["type"] == "file":
266            if "doc" not in d:
267                log(f"::notice file={f}::file doc missing")
268                pass
269
270        if d["type"] == "r_device":
271            if "decode_fn" not in d:
272                err(f"::error file={f}::device missing ({json.dumps(d)})")
273            elif d["decode_fn"] not in symbols:
274                err(f"::error file={f}::decoder missing ({d['decode_fn']})")
275
276        if d["type"] == "func":
277            if "line" not in d:
278                err(f"::error file={f}::func missing")
279            if "doc" not in d or not d["doc"]:
280                #err(f"::error file={f}::doc missing")
281                pass
282
283        if d["type"] == "model":
284            func = d["func"]
285            if func not in models_by_func:
286                models_by_func[func] = []
287            models_by_func[func].append(f)
288
289    for f in symbols:
290        d = symbols[f]
291
292        if f == "protocols":
293            continue
294
295        if d["type"] == "r_device":
296            if "decode_fn" not in d:
297                err(f"::error file={f}::no decode_fn found ({d['src']})")
298                continue
299            decode_fn = d["decode_fn"]
300            func = {}
301            if decode_fn in symbols:
302                func = symbols[decode_fn]
303            else:
304                err(f"::error file={f}::decode_fn not found ({decode_fn})")
305            see = None
306            if "doc_see" in func:
307                see = func["doc_see"]
308                if see not in symbols:
309                    err(f"::error file={f}::broken link for @sa ({see})")
310
311            if see and see in models_by_func:
312                # err(f"::error file={f}::models on sa link ({see})")
313                pass
314            elif decode_fn not in models_by_func:
315                err(f"::error file={f}::models not found ({d['src']})")
316                if see:
317                    err(f"::error file={f}::but @sa ({func['doc_see']})")
318
319
320def main(args):
321    """Scan basedir for all groups, devices, sets, and content."""
322
323    # ../include/rtl_433_devices.h
324    #    DECL(prologue)
325
326    check = "check" in args
327    if check:
328        args.remove("check")
329        errout = sys.stdout
330    root = (['.'] + args)[-1]
331    basedir = root + '/src/devices/'
332    declpath = root + '/include/rtl_433_devices.h'
333
334    symbols = {}
335
336    symbols['protocols'] = process_protocols(declpath)
337
338    for f in listdir(basedir):
339        if f.endswith('.c'):
340            symbols.update(process_source(basedir, f))
341
342    check_symbols(symbols)
343    if check:
344        return haserr
345    else:
346        # print(symbols)
347        # print(json.dumps(symbols, indent=2))
348        print(json.dumps(symbols))
349
350
351if __name__ == '__main__':
352    sys.exit(main(sys.argv[1:]))
353