Stefan Schuermans commited on 2017-05-26 14:52:09
Showing 6 changed files, with 344 additions and 20 deletions.
| ... | ... |
@@ -1,5 +1,7 @@ |
| 1 |
-import msg |
|
| 2 |
-import parse |
|
| 1 |
+from distributor import Distributor |
|
| 2 |
+from mapping import Mapping |
|
| 3 |
+from msg import Msg, MsgDef |
|
| 4 |
+from parse import parse_addr, parse_no, parse_two_nos |
|
| 3 | 5 |
|
| 4 | 6 |
|
| 5 | 7 |
class Display(object): |
| ... | ... |
@@ -10,19 +12,39 @@ class Display(object): |
| 10 | 12 |
msg_obj: message callback object or None""" |
| 11 | 13 |
self._msg = msg_obj |
| 12 | 14 |
if self._msg is None: # use default message callback if none given |
| 13 |
- self._msg = msg.MsgDef() |
|
| 15 |
+ self._msg = MsgDef() |
|
| 14 | 16 |
# default settings |
| 15 | 17 |
self._bind_addr = ("0.0.0.0", 0) # local network address to bind to
|
| 16 | 18 |
self._size = (0, 0) # size of display |
| 19 |
+ self._distris = {}
|
|
| 17 | 20 |
# read config file |
| 18 | 21 |
if not self._proc_config_file(config_file): |
| 19 | 22 |
raise Exception("error(s) while reading config file")
|
| 20 | 23 |
|
| 24 |
+ def get_size(self): |
|
| 25 |
+ """get size of display as (width, height) in pixels""" |
|
| 26 |
+ return self._size |
|
| 27 |
+ |
|
| 28 |
+ def data_clear(self): |
|
| 29 |
+ """clear image data, i.e. set entire image to black""" |
|
| 30 |
+ # TODO |
|
| 31 |
+ pass |
|
| 32 |
+ |
|
| 33 |
+ def data_set(self, TODO): |
|
| 34 |
+ """set image data""" |
|
| 35 |
+ # TODO |
|
| 36 |
+ pass |
|
| 37 |
+ |
|
| 38 |
+ def send(self): |
|
| 39 |
+ """send image data to distributors""" |
|
| 40 |
+ # TODO |
|
| 41 |
+ pass |
|
| 42 |
+ |
|
| 21 | 43 |
def _proc_config_file(self, config_file): |
| 22 | 44 |
"""process config file |
| 23 | 45 |
config_file: name of config file to read |
| 24 | 46 |
returns True on success, False on error""" |
| 25 |
- self._msg.msg(msg.Msg.INFO, "using config file \"%s\"" % config_file) |
|
| 47 |
+ self._msg.msg(Msg.INFO, "using config file \"%s\"" % config_file) |
|
| 26 | 48 |
# process all lines in config file |
| 27 | 49 |
okay = True |
| 28 | 50 |
try: |
| ... | ... |
@@ -33,10 +55,65 @@ class Display(object): |
| 33 | 55 |
okay = False |
| 34 | 56 |
lineno += 1 |
| 35 | 57 |
except (IOError, OSError) as e: |
| 36 |
- self._msg.msg(msg.Msg.ERR, str(e)) |
|
| 58 |
+ self._msg.msg(Msg.ERR, str(e)) |
|
| 37 | 59 |
okay = False |
| 38 | 60 |
return okay |
| 39 | 61 |
|
| 62 |
+ def _proc_config_distri(self, setting, value, lineno): |
|
| 63 |
+ """process distributor line from config file""" |
|
| 64 |
+ # distributor number |
|
| 65 |
+ distri_str = setting.split(" ", 1)[1]
|
|
| 66 |
+ distri_no = parse_no(distri_str) |
|
| 67 |
+ if distri_no is None: |
|
| 68 |
+ self._msg.msg(Msg.ERR, "invalid distributor number \"%s\"" |
|
| 69 |
+ " in line %u of config file" % |
|
| 70 |
+ (distri_str, lineno)) |
|
| 71 |
+ return False |
|
| 72 |
+ # number of outputs and pixels per output |
|
| 73 |
+ outputs_pixels = parse_two_nos(value) |
|
| 74 |
+ if outputs_pixels is None: |
|
| 75 |
+ self._msg.msg(Msg.ERR, "invalid distributor size \"%s\"" |
|
| 76 |
+ " in line %u of config file" % |
|
| 77 |
+ (value, lineno)) |
|
| 78 |
+ return False |
|
| 79 |
+ (outputs, pixels) = outputs_pixels |
|
| 80 |
+ # check if distributor is already present |
|
| 81 |
+ if distri_no in self._distris: |
|
| 82 |
+ self._msg.msg(Msg.ERR, "duplicate defintion of distributor %u" |
|
| 83 |
+ " in line %u of config file" % |
|
| 84 |
+ (distri_no, lineno)) |
|
| 85 |
+ return False |
|
| 86 |
+ # create distributor |
|
| 87 |
+ self._distris[distri_no] = Distributor(distri_no, outputs, pixels) |
|
| 88 |
+ return True |
|
| 89 |
+ |
|
| 90 |
+ def _proc_config_distri_addr(self, setting, value, lineno): |
|
| 91 |
+ """process distributor address line from config file""" |
|
| 92 |
+ # distributor number |
|
| 93 |
+ distri_str = setting.split(" ", 1)[1]
|
|
| 94 |
+ distri_no = parse_no(distri_str) |
|
| 95 |
+ if distri_no is None: |
|
| 96 |
+ self._msg.msg(Msg.ERR, "invalid distributor number \"%s\"" |
|
| 97 |
+ " in line %u of config file" % |
|
| 98 |
+ (distri_str, lineno)) |
|
| 99 |
+ return False |
|
| 100 |
+ # number of outputs and pixels per output |
|
| 101 |
+ addr = parse_addr(value) |
|
| 102 |
+ if addr is None: |
|
| 103 |
+ self._msg.msg(Msg.ERR, "invalid distributor address \"%s\"" |
|
| 104 |
+ " in line %u of config file" % |
|
| 105 |
+ (value, lineno)) |
|
| 106 |
+ return False |
|
| 107 |
+ # check if distributor exists |
|
| 108 |
+ if distri_no not in self._distris: |
|
| 109 |
+ self._msg.msg(Msg.ERR, "no distributor with number %u" |
|
| 110 |
+ " in line %u of config file" % |
|
| 111 |
+ (distri_no, lineno)) |
|
| 112 |
+ return False |
|
| 113 |
+ # set address |
|
| 114 |
+ self._distris[distri_no].set_addr(addr) |
|
| 115 |
+ return True |
|
| 116 |
+ |
|
| 40 | 117 |
def _proc_config_line(self, line, lineno): |
| 41 | 118 |
"""process line from config file |
| 42 | 119 |
line: line read from config file |
| ... | ... |
@@ -49,7 +126,7 @@ class Display(object): |
| 49 | 126 |
else: |
| 50 | 127 |
fields = line_no_comment.split("=", 1)
|
| 51 | 128 |
if len(fields) < 2: |
| 52 |
- self._msg.msg(msg.Msg.WARN, |
|
| 129 |
+ self._msg.msg(Msg.WARN, |
|
| 53 | 130 |
"invalid line %u in config file, ignored" |
| 54 | 131 |
% lineno) |
| 55 | 132 |
return True |
| ... | ... |
@@ -58,6 +135,149 @@ class Display(object): |
| 58 | 135 |
value = fields[1].strip() |
| 59 | 136 |
return self._proc_config_setting(setting, value, lineno) |
| 60 | 137 |
|
| 138 |
+ def _proc_config_mapping(self, setting, value, lineno): |
|
| 139 |
+ """process mapping line from config file""" |
|
| 140 |
+ fields = setting.split() |
|
| 141 |
+ if len(fields) != 3: |
|
| 142 |
+ self._msg.msg(Msg.ERR, "invalid mapping specifier \"%s\"" |
|
| 143 |
+ " in line %u of config file" % |
|
| 144 |
+ (setting, lineno)) |
|
| 145 |
+ return False |
|
| 146 |
+ # distributor number |
|
| 147 |
+ distri_str = fields[1] |
|
| 148 |
+ distri_no = parse_no(distri_str) |
|
| 149 |
+ if distri_no is None: |
|
| 150 |
+ self._msg.msg(Msg.ERR, "invalid distributor number \"%s\"" |
|
| 151 |
+ " in line %u of config file" % |
|
| 152 |
+ (distri_str, lineno)) |
|
| 153 |
+ return False |
|
| 154 |
+ # color channel |
|
| 155 |
+ color_str = fields[2] |
|
| 156 |
+ if color_str == "red": |
|
| 157 |
+ color = Mapping.RED |
|
| 158 |
+ elif color_str == "green": |
|
| 159 |
+ color = Mapping.GREEN |
|
| 160 |
+ elif color_str == "blue": |
|
| 161 |
+ color = Mapping.BLUE |
|
| 162 |
+ else: |
|
| 163 |
+ self._msg.msg(Msg.ERR, "invalid color channel \"%s\"" |
|
| 164 |
+ " in line %u of config file" % |
|
| 165 |
+ (color_str, lineno)) |
|
| 166 |
+ return False |
|
| 167 |
+ # mapping parameters |
|
| 168 |
+ params = value.split() |
|
| 169 |
+ if len(params) != 3: |
|
| 170 |
+ self._msg.msg(Msg.ERR, "invalid mapping parameters \"%s\"" |
|
| 171 |
+ " in line %u of config file" % |
|
| 172 |
+ (value, lineno)) |
|
| 173 |
+ return False |
|
| 174 |
+ try: |
|
| 175 |
+ base = float(params[0]) |
|
| 176 |
+ except: |
|
| 177 |
+ self._msg.msg(Msg.ERR, "invalid base value \"%s\"" |
|
| 178 |
+ " in line %u of config file" % |
|
| 179 |
+ (params[0], lineno)) |
|
| 180 |
+ return False; |
|
| 181 |
+ try: |
|
| 182 |
+ factor = float(params[1]) |
|
| 183 |
+ except: |
|
| 184 |
+ self._msg.msg(Msg.ERR, "invalid factor value \"%s\"" |
|
| 185 |
+ " in line %u of config file" % |
|
| 186 |
+ (params[1], lineno)) |
|
| 187 |
+ return False; |
|
| 188 |
+ try: |
|
| 189 |
+ gamma = float(params[2]) |
|
| 190 |
+ if gamma <= 0.0: |
|
| 191 |
+ raise ValueError |
|
| 192 |
+ except: |
|
| 193 |
+ self._msg.msg(Msg.ERR, "invalid gamma value \"%s\"" |
|
| 194 |
+ " in line %u of config file" % |
|
| 195 |
+ (params[2], lineno)) |
|
| 196 |
+ return False |
|
| 197 |
+ # check if distributor exists |
|
| 198 |
+ if distri_no not in self._distris: |
|
| 199 |
+ self._msg.msg(Msg.ERR, "no distributor with number %u" |
|
| 200 |
+ " in line %u of config file" % |
|
| 201 |
+ (distri_no, lineno)) |
|
| 202 |
+ return False |
|
| 203 |
+ # set mapping |
|
| 204 |
+ self._distris[distri_no].set_mapping(color, base, factor, gamma) |
|
| 205 |
+ return True |
|
| 206 |
+ |
|
| 207 |
+ def _proc_config_output(self, setting, value, lineno): |
|
| 208 |
+ """process output line from config file""" |
|
| 209 |
+ fields = setting.split() |
|
| 210 |
+ if len(fields) != 2: |
|
| 211 |
+ self._msg.msg(Msg.ERR, "invalid output specifier \"%s\"" |
|
| 212 |
+ " in line %u of config file" % |
|
| 213 |
+ (setting, lineno)) |
|
| 214 |
+ return False |
|
| 215 |
+ # distributor number and output number |
|
| 216 |
+ distri_output_str = fields[1] |
|
| 217 |
+ distri_output = parse_two_nos(distri_output_str) |
|
| 218 |
+ if distri_output is None: |
|
| 219 |
+ self._msg.msg(Msg.ERR, "invalid distributor/output numbers \"%s\"" |
|
| 220 |
+ " in line %u of config file" % |
|
| 221 |
+ (distri_output_str, lineno)) |
|
| 222 |
+ return False |
|
| 223 |
+ (distri_no, output_no) = distri_output |
|
| 224 |
+ # check if distributor exists |
|
| 225 |
+ if distri_no not in self._distris: |
|
| 226 |
+ self._msg.msg(Msg.ERR, "no distributor with number %u" |
|
| 227 |
+ " in line %u of config file" % |
|
| 228 |
+ (distri_no, lineno)) |
|
| 229 |
+ return False |
|
| 230 |
+ # get distributor |
|
| 231 |
+ distri = self._distris[distri_no] |
|
| 232 |
+ # check if output exists |
|
| 233 |
+ if not distri.check_output_no(output_no): |
|
| 234 |
+ self._msg.msg(Msg.ERR, "no output with number %u for distributor" |
|
| 235 |
+ " %u in line %u of config file" % |
|
| 236 |
+ (output_no, distri_no, lineno)) |
|
| 237 |
+ return False |
|
| 238 |
+ # split pixels and check number |
|
| 239 |
+ max_pixels = distri.get_pixels() |
|
| 240 |
+ pixel_data = value.split() |
|
| 241 |
+ if len(pixel_data) < max_pixels: |
|
| 242 |
+ self._msg.msg(Msg.ERR, "too many pixels (%u, more than %u)" |
|
| 243 |
+ " for distributor %u, output %u" |
|
| 244 |
+ " in line %u of config file" % |
|
| 245 |
+ (len(pixel_data), max_pixels, |
|
| 246 |
+ distri_no, output_no, lineno)) |
|
| 247 |
+ return False |
|
| 248 |
+ # parse pixels |
|
| 249 |
+ err = False |
|
| 250 |
+ pixel_coords = [] |
|
| 251 |
+ i = 0 |
|
| 252 |
+ for pixel_str in pixel_data: |
|
| 253 |
+ pixel_xy = parse_two_nos(pixel_str) |
|
| 254 |
+ if pixel_xy is None: |
|
| 255 |
+ self._msg.msg(Msg.ERR, "invalid pixel coordinates \"%s\"" |
|
| 256 |
+ " for pixel %u" |
|
| 257 |
+ " at distributor %u, output %u" |
|
| 258 |
+ " in line %u of config file" % |
|
| 259 |
+ (pixel_str, i, |
|
| 260 |
+ distri_no, output_no, lineno)) |
|
| 261 |
+ err = True |
|
| 262 |
+ else: |
|
| 263 |
+ if pixel_xy[0] < 0 or pixel_xy[0] >= self._size[0] or \ |
|
| 264 |
+ pixel_xy[1] < 0 or pixel_xy[1] >= self._size[1]: |
|
| 265 |
+ self._msg.msg(Msg.ERR, "pixel coordinates %u,%u" |
|
| 266 |
+ " outside of frame for pixel %u" |
|
| 267 |
+ " at distributor %u, output %u" |
|
| 268 |
+ " in line %u of config file" % |
|
| 269 |
+ (pixel_xy, i, |
|
| 270 |
+ distri_no, output_no, lineno)) |
|
| 271 |
+ pixel_xy = None |
|
| 272 |
+ err = True |
|
| 273 |
+ pixel_coords.append(pixel_xy) |
|
| 274 |
+ i += 1 |
|
| 275 |
+ if err: |
|
| 276 |
+ return False |
|
| 277 |
+ # set pixels |
|
| 278 |
+ distri.set_pixel_coords(output_no, pixel_coords) |
|
| 279 |
+ return True |
|
| 280 |
+ |
|
| 61 | 281 |
def _proc_config_setting(self, setting, value, lineno): |
| 62 | 282 |
"""process setting from config file |
| 63 | 283 |
setting: name of setting |
| ... | ... |
@@ -67,22 +287,22 @@ class Display(object): |
| 67 | 287 |
setting = " ".join(setting.split()) |
| 68 | 288 |
# process setting |
| 69 | 289 |
if setting == "bindAddr": |
| 70 |
- addr = parse._parse_addr(value) |
|
| 290 |
+ addr = parse_addr(value) |
|
| 71 | 291 |
if addr is None: |
| 72 |
- self._msg.msg(msg.Msg.ERR, |
|
| 292 |
+ self._msg.msg(Msg.ERR, |
|
| 73 | 293 |
"invalid address \"%s\" for \"bindAddr\"" |
| 74 | 294 |
" in line %u in config file" |
| 75 | 295 |
% (value, lineno)) |
| 76 | 296 |
return False |
| 77 | 297 |
else: |
| 78 | 298 |
self._bind_addr = addr |
| 79 |
- self._msg.msg(msg.Msg.INFO, |
|
| 299 |
+ self._msg.msg(Msg.INFO, |
|
| 80 | 300 |
"bind address \"%s:%u\"" % (addr[0], addr[1])) |
| 81 | 301 |
return True |
| 82 | 302 |
elif setting == "size": |
| 83 |
- size = parse._parse_two_nos(value) |
|
| 303 |
+ size = parse_two_nos(value) |
|
| 84 | 304 |
if size is None: |
| 85 |
- self._msg.msg(msg.Msg.ERR, |
|
| 305 |
+ self._msg.msg(Msg.ERR, |
|
| 86 | 306 |
"invalid address \"%s\" for \"size\"" |
| 87 | 307 |
" in line %u in config file" |
| 88 | 308 |
% (value, lineno)) |
| ... | ... |
@@ -91,15 +311,15 @@ class Display(object): |
| 91 | 311 |
self._size = size |
| 92 | 312 |
return True |
| 93 | 313 |
elif setting.startswith("distributor "):
|
| 94 |
- return True # TODO |
|
| 314 |
+ return self._proc_config_distri(setting, value, lineno) |
|
| 95 | 315 |
elif setting.startswith("distributorAddr "):
|
| 96 |
- return True # TODO |
|
| 316 |
+ return self._proc_config_distri_addr(setting, value, lineno) |
|
| 97 | 317 |
elif setting.startswith("mapping "):
|
| 98 |
- return True # TODO |
|
| 318 |
+ return self._proc_config_mapping(setting, value, lineno) |
|
| 99 | 319 |
elif setting.startswith("output "):
|
| 100 |
- return True # TODO |
|
| 320 |
+ return self._proc_config_output(setting, value, lineno) |
|
| 101 | 321 |
else: |
| 102 |
- self._msg.msg(msg.Msg.WARN, |
|
| 322 |
+ self._msg.msg(Msg.WARN, |
|
| 103 | 323 |
"unknown setting \"%s\" in line %u in config file," |
| 104 | 324 |
" ignored" % (setting, lineno)) |
| 105 | 325 |
return True |
| ... | ... |
@@ -0,0 +1,52 @@ |
| 1 |
+from mapping import Mapping |
|
| 2 |
+ |
|
| 3 |
+ |
|
| 4 |
+class Distributor(object): |
|
| 5 |
+ |
|
| 6 |
+ def __init__(self, distri_no, outputs, pixels): |
|
| 7 |
+ """create a new EtherPix distributor |
|
| 8 |
+ distri_no: distributor number |
|
| 9 |
+ outputs: number of outputs |
|
| 10 |
+ pixels: number of pixels per output""" |
|
| 11 |
+ self._distri_no = distri_no |
|
| 12 |
+ self._outputs = outputs |
|
| 13 |
+ self._pixels = pixels |
|
| 14 |
+ # default address |
|
| 15 |
+ self._addr = ("10.70.80.%u" % distri_no, 2323)
|
|
| 16 |
+ # default color mapping |
|
| 17 |
+ self._mappings = [] |
|
| 18 |
+ for c in range(Mapping.CHANNELS): |
|
| 19 |
+ self._mappings.append(Mapping()) |
|
| 20 |
+ # pixel coordinates: all unknown |
|
| 21 |
+ self._pixel_coords = [[None] * self._pixels] * self._outputs |
|
| 22 |
+ |
|
| 23 |
+ def check_output_no(self, output_no): |
|
| 24 |
+ """check output number, return True if okay, False if not okay""" |
|
| 25 |
+ return output_no >= 0 and output_no < self._outputs |
|
| 26 |
+ |
|
| 27 |
+ def get_outputs(self): |
|
| 28 |
+ """get number of outputs""" |
|
| 29 |
+ return self._outputs |
|
| 30 |
+ |
|
| 31 |
+ def get_pixels(self): |
|
| 32 |
+ """get number of pixels per output""" |
|
| 33 |
+ return self._pixels |
|
| 34 |
+ |
|
| 35 |
+ def set_addr(self, addr): |
|
| 36 |
+ """set distributor address""" |
|
| 37 |
+ self._addr = addr |
|
| 38 |
+ |
|
| 39 |
+ def set_pixel_coords(self, output_no, pixel_coords): |
|
| 40 |
+ """set distributor address |
|
| 41 |
+ output_no: number of output |
|
| 42 |
+ pixel_coords: list of (x,y) coordinates of pixels at this output, |
|
| 43 |
+ some list entries may be None""" |
|
| 44 |
+ self._pixel_coords[output_no] = pixel_coords |
|
| 45 |
+ if len(self._pixel_coords[output_no]) < self._pixels: |
|
| 46 |
+ pad_cnt = self._pixels - len(self._pixel_coords[output_no]) |
|
| 47 |
+ self._pixel_coords[output_no] += [None] * pad_cnt |
|
| 48 |
+ |
|
| 49 |
+ def set_mapping(self, color, base, factor, gamma): |
|
| 50 |
+ """set distributor mapping for one color channel""" |
|
| 51 |
+ self._mappings[color].set_params(base, factor, gamma) |
|
| 52 |
+ |
| ... | ... |
@@ -0,0 +1,36 @@ |
| 1 |
+class Mapping(object): |
|
| 2 |
+ |
|
| 3 |
+ RED = 0 |
|
| 4 |
+ GREEN = 1 |
|
| 5 |
+ BLUE = 2 |
|
| 6 |
+ CHANNELS = 3 |
|
| 7 |
+ |
|
| 8 |
+ def __init__(self): |
|
| 9 |
+ """create a new color mapping for an EtherPix distributor""" |
|
| 10 |
+ self._base = 0.0 |
|
| 11 |
+ self._gamma = 1.0 |
|
| 12 |
+ self._factor = 1.0 |
|
| 13 |
+ self._precompute() |
|
| 14 |
+ |
|
| 15 |
+ def set_params(self, base, gamma, factor): |
|
| 16 |
+ """set mapping parameters""" |
|
| 17 |
+ self._base = base |
|
| 18 |
+ self._gamma = gamma |
|
| 19 |
+ self._factor = factor |
|
| 20 |
+ self._precompute() |
|
| 21 |
+ |
|
| 22 |
+ def _precompute(self): |
|
| 23 |
+ """pre-compute mapping table""" |
|
| 24 |
+ # display_value := base + factor * input_value ^ (1 / gamma) |
|
| 25 |
+ gamma_1 = 1.0 / self._gamma |
|
| 26 |
+ tab = [] |
|
| 27 |
+ for v in range(256): |
|
| 28 |
+ val = self._base + self._factor * (v / 255.0) ** gamma_1 |
|
| 29 |
+ if val < 0: |
|
| 30 |
+ tab.append(0) |
|
| 31 |
+ elif val > 1.0: |
|
| 32 |
+ tab.append(255) |
|
| 33 |
+ else: |
|
| 34 |
+ tab.append(int(round(val * 255.0))) |
|
| 35 |
+ self._table = tuple(tab) |
|
| 36 |
+ |
| ... | ... |
@@ -1,4 +1,4 @@ |
| 1 |
-def _parse_addr(addr_str): |
|
| 1 |
+def parse_addr(addr_str): |
|
| 2 | 2 |
"""parse an IPv4 address, e.g. \"1.2.3.4:567\", |
| 3 | 3 |
return tuple of IP and port, e.g. ("1.2.3.4", 567), None on error"""
|
| 4 | 4 |
fields = addr_str.split(":")
|
| ... | ... |
@@ -16,7 +16,21 @@ def _parse_addr(addr_str): |
| 16 | 16 |
return None |
| 17 | 17 |
return (ip, port) |
| 18 | 18 |
|
| 19 |
-def _parse_two_nos(txt): |
|
| 19 |
+def parse_no(txt): |
|
| 20 |
+ """parse a decimal unsigned integer from string, |
|
| 21 |
+ e.g. \"12\", number, e.g. 12, None on error""" |
|
| 22 |
+ fields = txt.strip().split() |
|
| 23 |
+ if len(fields) > 1: |
|
| 24 |
+ return None |
|
| 25 |
+ try: |
|
| 26 |
+ no = int(fields[0]) |
|
| 27 |
+ except: |
|
| 28 |
+ return None |
|
| 29 |
+ if no < 0: |
|
| 30 |
+ return None |
|
| 31 |
+ return no |
|
| 32 |
+ |
|
| 33 |
+def parse_two_nos(txt): |
|
| 20 | 34 |
"""parse two comma separated decimal unsigned integers from string, |
| 21 | 35 |
e.g. \"12,34\", return tuple of two numbers, e.g. (12, 34), |
| 22 | 36 |
None on error""" |
| 23 | 37 |