Revision 7adf90be examples/yarp_icub/src/icub_jointinterface.cpp
examples/yarp_icub/src/icub_jointinterface.cpp | ||
---|---|---|
16 | 16 |
*/ |
17 | 17 |
|
18 | 18 |
//WARNING: DO NOT CHANGE THIS; VELOCITYMODE IS NOT YET IMPLEMENTED |
19 |
#define POSITION_CONTROL 1
|
|
19 |
#define POSITION_CONTROL 0
|
|
20 | 20 |
|
21 | 21 |
|
22 | 22 |
//! constructor |
... | ... | |
45 | 45 |
enum_id_bimap.insert(enum_id_bimap_entry_t(ICUB_ID_NECK_ROLL, ID_NECK_ROLL)); |
46 | 46 |
|
47 | 47 |
//EYES |
48 |
enum_id_bimap.insert(enum_id_bimap_entry_t(ICUB_ID_EYES_LEFT_LR, ID_EYES_LEFT_LR));
|
|
49 |
enum_id_bimap.insert(enum_id_bimap_entry_t(ICUB_ID_EYES_RIGHT_LR, ID_EYES_RIGHT_LR));
|
|
48 |
enum_id_bimap.insert(enum_id_bimap_entry_t(ICUB_ID_EYES_PAN, ID_EYES_LEFT_LR));
|
|
49 |
enum_id_bimap.insert(enum_id_bimap_entry_t(ICUB_ID_EYES_VERGENCE, ID_EYES_RIGHT_LR));
|
|
50 | 50 |
enum_id_bimap.insert(enum_id_bimap_entry_t(ICUB_ID_EYES_BOTH_UD, ID_EYES_BOTH_UD)); |
51 | 51 |
|
52 | 52 |
//EYELIDS |
... | ... | |
69 | 69 |
dd.view(ipos); |
70 | 70 |
dd.view(ivel); |
71 | 71 |
dd.view(ilimits); |
72 |
dd.view(pid); |
|
73 |
dd.view(amp); |
|
72 | 74 |
|
73 |
if ( (!iencs) || (!ipos) || (!ilimits) || (!ivel)){ |
|
75 |
|
|
76 |
if ( (!iencs) || (!ipos) || (!ilimits) || (!ivel) || (!amp) || (!pid)){ |
|
74 | 77 |
printf("> ERROR: failed to open icub views\n"); |
75 | 78 |
exit(EXIT_FAILURE); |
76 | 79 |
} |
... | ... | |
92 | 95 |
ipos->setPositionMode(); |
93 | 96 |
}else{ |
94 | 97 |
ivel->setVelocityMode(); |
95 |
commands=1000000;
|
|
98 |
commands=100.0;
|
|
96 | 99 |
ivel->setRefAccelerations(commands.data()); |
97 | 100 |
} |
98 | 101 |
|
... | ... | |
169 | 172 |
Bottle &cmd = emotion_port[0].prepare(); |
170 | 173 |
cmd.clear(); |
171 | 174 |
cmd.addString(buf); |
172 |
emotion_port[0].write(); |
|
175 |
emotion_port[0].writeStrict();
|
|
173 | 176 |
}else{ |
174 | 177 |
printf("> ERROR: no icub emotion output\n"); |
175 | 178 |
exit(EXIT_FAILURE); |
... | ... | |
227 | 230 |
Bottle &cmd = emotion_port[port_id].prepare(); |
228 | 231 |
cmd.clear(); |
229 | 232 |
cmd.addString(cmd_s); |
230 |
emotion_port[port_id].write(); |
|
233 |
emotion_port[port_id].writeStrict();
|
|
231 | 234 |
}else{ |
232 | 235 |
printf("> ERROR: no icub emotion output\n"); |
233 | 236 |
exit(EXIT_FAILURE); |
... | ... | |
252 | 255 |
if (id == ICUB_ID_NECK_PAN){ |
253 | 256 |
//PAN seems to be swapped |
254 | 257 |
store_joint(ICUB_ID_NECK_PAN, -joint_target[e]); |
255 |
}else if ((id == ICUB_ID_EYES_LEFT_LR) || ( id == ICUB_ID_EYES_RIGHT_LR)){
|
|
258 |
}else if ((id == ICUB_ID_EYES_PAN) || ( id == ICUB_ID_EYES_VERGENCE)){
|
|
256 | 259 |
//icub handles eyes differently, we have to set pan angle + vergence |
257 | 260 |
float pan = (joint_target[ID_EYES_LEFT_LR] + joint_target[ID_EYES_RIGHT_LR]) / 2; |
258 | 261 |
float vergence = (joint_target[ID_EYES_LEFT_LR] - joint_target[ID_EYES_RIGHT_LR]); |
... | ... | |
283 | 286 |
printf("> ERROR: set_target_positionmode(id=%d, %3.2f) not supported for this id\n",id,value); |
284 | 287 |
return; |
285 | 288 |
} |
286 |
#if 0 |
|
287 |
//set speed cacluated as in velocity + set position -> replicates smoothmotion from flobi?! |
|
288 |
|
|
289 |
//first: calculate necessary speed to reach the given target within the next clock tick: |
|
290 |
double distance = value - target_angle_previous[id]; |
|
291 |
//make the motion smooth: we want to reach 85% of the target in the next iteration: |
|
292 |
//calculate speed for that: |
|
293 |
double speed = 0.85 * distance * ((double)MAIN_LOOP_FREQUENCY); |
|
294 | 289 |
|
295 |
//set up speed for controller: |
|
296 |
ipos->setRefSpeed(id, speed); |
|
297 |
#endif |
|
298 |
//execute motion |
|
290 |
// execute motion as position control cmd |
|
299 | 291 |
ipos->positionMove(id, value); |
292 |
|
|
300 | 293 |
} |
301 | 294 |
|
302 | 295 |
//! execute a move in velocity mode |
303 | 296 |
//! \param id of joint |
304 | 297 |
//! \param angle |
305 | 298 |
void iCubJointInterface::set_target_in_velocitymode(int id, double value){ |
299 |
// set speed cacluated as in velocity + set position -> replicates smoothmotion from flobi?! |
|
306 | 300 |
//first: calculate necessary speed to reach the given target within the next clock tick: |
307 | 301 |
double distance = value - target_angle_previous[id]; |
302 |
|
|
308 | 303 |
//make the motion smooth: we want to reach 85% of the target in the next iteration: |
309 | 304 |
distance = 0.85 * distance; |
305 |
|
|
306 |
//distance = -5.0 / 50.0; |
|
307 |
|
|
310 | 308 |
//calculate speed |
311 |
double speed = distance * ((double)MAIN_LOOP_FREQUENCY); |
|
309 |
//double speed = distance * ((double)MAIN_LOOP_FREQUENCY); |
|
310 |
|
|
311 |
|
|
312 |
|
|
313 |
int e = convert_motorid_to_enum(id); |
|
314 |
double speed = joint_target_speed[e]; |
|
315 |
|
|
316 |
double max = 150.0; |
|
317 |
if (speed > max) speed = max; |
|
318 |
if (speed < -max) speed = -max; |
|
319 |
|
|
320 |
//speed = -speed; |
|
321 |
|
|
322 |
|
|
312 | 323 |
//execute: |
313 |
ivel->velocityMove(id, speed); |
|
314 |
//if (id == ICUB_ID_NECK_PAN) printf("> VEL now=%3.2f target=%3.2f --> dist=%3.2f speed=%3.2f\n",target_angle_previous[id],value,distance,speed); |
|
324 |
//ivel->velocityMove(id, speed); |
|
325 |
if ((id == ICUB_ID_NECK_PAN) || (id == ICUB_ID_EYES_BOTH_UD) || (id == ICUB_ID_NECK_TILT) || (id == ICUB_ID_EYES_BOTH_UD) || (id == ICUB_ID_NECK_TILT) ){ |
|
326 |
if (id == ICUB_ID_NECK_PAN) speed = -speed; |
|
327 |
ivel->velocityMove(id, speed); |
|
328 |
printf("> VEL now=%3.2f target=%3.2f --> dist=%3.2f speed=%3.2f\n",target_angle_previous[id],value,distance,speed); |
|
329 |
} |
|
315 | 330 |
|
316 | 331 |
target_angle_previous[id] = get_ts_position(convert_motorid_to_enum(id)).get_newest_value(); |
317 | 332 |
} |
... | ... | |
416 | 431 |
Bottle &cmd = emotion_port[3].prepare(); |
417 | 432 |
cmd.clear(); |
418 | 433 |
cmd.addString(buf); |
419 |
emotion_port[3].write(); |
|
434 |
emotion_port[3].writeStrict();
|
|
420 | 435 |
|
421 | 436 |
|
422 | 437 |
//store joint values which we do not handle on icub here: |
... | ... | |
451 | 466 |
JointInterface::store_incoming_position(ID_EYES_RIGHT_LID_UPPER, lid_angle, timestamp); |
452 | 467 |
break; |
453 | 468 |
|
454 |
case(2):
|
|
469 |
case(ICUB_ID_NECK_PAN):
|
|
455 | 470 |
//PAN is inverted! |
456 | 471 |
JointInterface::store_incoming_position(ID_NECK_PAN, -value, timestamp); |
457 | 472 |
break; |
458 | 473 |
|
459 |
case(0):
|
|
474 |
case(ICUB_ID_NECK_TILT):
|
|
460 | 475 |
JointInterface::store_incoming_position(ID_NECK_TILT, value, timestamp); |
461 | 476 |
break; |
462 | 477 |
|
463 |
case(1):
|
|
478 |
case(ICUB_ID_NECK_ROLL):
|
|
464 | 479 |
JointInterface::store_incoming_position(ID_NECK_ROLL, value, timestamp); |
465 | 480 |
break; |
466 | 481 |
|
467 |
case(3):
|
|
482 |
case(ICUB_ID_EYES_BOTH_UD):
|
|
468 | 483 |
JointInterface::store_incoming_position(ID_EYES_BOTH_UD, value, timestamp); |
469 | 484 |
break; |
470 | 485 |
|
471 | 486 |
//icub handles eyes differently, we have to set pan angle + vergence |
472 |
case(4): {//pan
|
|
487 |
case(ICUB_ID_EYES_PAN): {//pan
|
|
473 | 488 |
last_pos_eye_pan = value; |
474 | 489 |
float left = last_pos_eye_pan + last_pos_eye_vergence/2.0; |
475 | 490 |
float right = last_pos_eye_pan - last_pos_eye_vergence/2.0; |
... | ... | |
480 | 495 |
break; |
481 | 496 |
} |
482 | 497 |
|
483 |
case(5): { //vergence
|
|
498 |
case(ICUB_ID_EYES_VERGENCE): { //vergence
|
|
484 | 499 |
last_pos_eye_vergence = value; |
485 | 500 |
float left = last_pos_eye_pan + last_pos_eye_vergence/2.0; |
486 | 501 |
float right = last_pos_eye_pan - last_pos_eye_vergence/2.0; |
... | ... | |
506 | 521 |
printf("> ERROR: unhandled joint id %d\n",id); |
507 | 522 |
return; |
508 | 523 |
|
509 |
case(2): |
|
510 |
JointInterface::store_incoming_speed(ID_NECK_PAN, value, timestamp); |
|
524 |
case(ICUB_ID_NECK_PAN): |
|
525 |
//PAN IS INVERTED |
|
526 |
JointInterface::store_incoming_speed(ID_NECK_PAN, -value, timestamp); |
|
511 | 527 |
break; |
512 | 528 |
|
513 |
case(0):
|
|
529 |
case(ICUB_ID_NECK_TILT):
|
|
514 | 530 |
JointInterface::store_incoming_speed(ID_NECK_TILT, value, timestamp); |
515 | 531 |
break; |
516 | 532 |
|
517 |
case(1):
|
|
533 |
case(ICUB_ID_NECK_ROLL):
|
|
518 | 534 |
JointInterface::store_incoming_speed(ID_NECK_ROLL, value, timestamp); |
519 | 535 |
break; |
520 | 536 |
|
521 |
case(3):
|
|
537 |
case(ICUB_ID_EYES_BOTH_UD):
|
|
522 | 538 |
JointInterface::store_incoming_speed(ID_EYES_BOTH_UD, value, timestamp); |
523 | 539 |
break; |
524 | 540 |
|
525 | 541 |
//icub handles eyes differently, we have to set pan angle + vergence |
526 |
case(4): {//pan
|
|
542 |
case(ICUB_ID_EYES_PAN): {//pan
|
|
527 | 543 |
last_vel_eye_pan = value; |
528 | 544 |
float left = last_vel_eye_pan + last_vel_eye_vergence/2.0; |
529 | 545 |
float right = last_vel_eye_pan - last_vel_eye_vergence/2.0; |
... | ... | |
534 | 550 |
break; |
535 | 551 |
} |
536 | 552 |
|
537 |
case(5): { //vergence
|
|
553 |
case(ICUB_ID_EYES_VERGENCE): { //vergence
|
|
538 | 554 |
last_vel_eye_pan = value; |
539 | 555 |
float left = last_vel_eye_pan + last_vel_eye_vergence/2.0; |
540 | 556 |
float right = last_vel_eye_pan - last_vel_eye_vergence/2.0; |
... | ... | |
545 | 561 |
break; |
546 | 562 |
} |
547 | 563 |
} |
548 |
/* |
|
549 |
JointInterface::store_incoming_speed(ID_LIP_LEFT_UPPER, 0.0, timestamp); |
|
550 |
JointInterface::store_incoming_speed(ID_LIP_LEFT_LOWER, 0.0, timestamp); |
|
551 |
JointInterface::store_incoming_speed(ID_LIP_CENTER_UPPER, 0.0, timestamp); |
|
552 |
JointInterface::store_incoming_speed(ID_LIP_CENTER_LOWER, 0.0, timestamp); |
|
553 |
JointInterface::store_incoming_speed(ID_LIP_RIGHT_UPPER, 0.0, timestamp); |
|
554 |
JointInterface::store_incoming_speed(ID_LIP_RIGHT_LOWER, 0.0, timestamp); |
|
555 |
|
|
556 |
JointInterface::store_incoming_speed(ID_EYES_LEFT_LID_LOWER, 0.0, timestamp); |
|
557 |
JointInterface::store_incoming_speed(ID_EYES_LEFT_LID_UPPER, 0.0, timestamp); |
|
558 |
JointInterface::store_incoming_speed(ID_EYES_LEFT_BROW, 0.0, timestamp); |
|
559 |
|
|
560 |
JointInterface::store_incoming_speed(ID_EYES_RIGHT_LID_LOWER, 0.0, timestamp); |
|
561 |
JointInterface::store_incoming_speed(ID_EYES_RIGHT_LID_UPPER,0.0, timestamp); |
|
562 |
JointInterface::store_incoming_speed(ID_EYES_RIGHT_BROW, 0.0, timestamp);*/ |
|
563 |
/* |
|
564 |
//fetch enum id: |
|
565 |
int e = convert_motorid_to_enum(device->get_device_id()); |
|
566 |
if (e == -1){ |
|
567 |
return; //we are not interested in that data, so we just return here |
|
568 |
} |
|
564 |
} |
|
569 | 565 |
|
570 |
JointInterface::store_incoming_speed(e, device->get_register(XSC3_REGISTER_MOTORSPEED), timestamp);*/ |
|
566 |
void iCubJointInterface::set_joint_enable_state(int e, bool enable) { |
|
567 |
int icub_jointid = -1; |
|
571 | 568 |
|
572 |
} |
|
573 |
/* |
|
574 |
//! conversion table for humotion motor ids to our ids: |
|
575 |
//! \param enum from JointInterface::JOINT_ID_ENUM |
|
576 |
//! \return int value of motor id |
|
577 |
int HumotionJointInterface::convert_enum_to_motorid(int e){ |
|
578 |
enum_id_bimap_t::right_const_iterator it = enum_id_bimap.right.find(e); |
|
579 |
if(it == enum_id_bimap.right.end()) { |
|
580 |
//key does not exists, we are not interested in that dataset, return -1 |
|
581 |
return -1; |
|
582 |
} |
|
569 |
switch(e){ |
|
570 |
default: |
|
571 |
break; |
|
583 | 572 |
|
584 |
return it->second; |
|
585 |
} |
|
573 |
case(ID_NECK_PAN): |
|
574 |
icub_jointid = ICUB_ID_NECK_PAN; |
|
575 |
break; |
|
586 | 576 |
|
577 |
case(ID_NECK_TILT): |
|
578 |
icub_jointid = ICUB_ID_NECK_TILT; |
|
579 |
break; |
|
587 | 580 |
|
588 |
//! conversion table for our ids to humotion motor ids: |
|
589 |
//! \param int value of motor id |
|
590 |
//! \return enum from JointInterface::JOINT_ID_ENUM |
|
591 |
int HumotionJointInterface::convert_motorid_to_enum(int id){ |
|
592 |
enum_id_bimap_t::left_const_iterator it = enum_id_bimap.left.find(id); |
|
593 |
if(it == enum_id_bimap.left.end()) { |
|
594 |
//key does not exists, we are not interested in that dataset, return -1 |
|
595 |
return -1; |
|
581 |
case(ID_NECK_ROLL): |
|
582 |
icub_jointid = ICUB_ID_NECK_ROLL; |
|
583 |
break; |
|
584 |
|
|
585 |
case(ID_EYES_BOTH_UD): |
|
586 |
icub_jointid = ICUB_ID_EYES_BOTH_UD; |
|
587 |
break; |
|
588 |
|
|
589 |
// icub handles eyes as pan angle + vergence... |
|
590 |
// -> hack: left eye enables pan and right eye enables vergence |
|
591 |
case(ID_EYES_LEFT_LR): |
|
592 |
icub_jointid = ICUB_ID_EYES_PAN; |
|
593 |
break; |
|
594 |
|
|
595 |
case(ID_EYES_RIGHT_LR): |
|
596 |
icub_jointid = ICUB_ID_EYES_VERGENCE; |
|
597 |
break; |
|
596 | 598 |
} |
597 | 599 |
|
598 |
return it->second; |
|
600 |
if (icub_jointid != -1) { |
|
601 |
if (enable) { |
|
602 |
amp->enableAmp(icub_jointid); |
|
603 |
pid->enablePid(icub_jointid); |
|
604 |
} else { |
|
605 |
pid->disablePid(icub_jointid); |
|
606 |
amp->disableAmp(icub_jointid); |
|
607 |
} |
|
608 |
} |
|
599 | 609 |
} |
600 |
*/ |
|
601 | 610 |
|
602 | 611 |
//! prepare and enable a joint |
603 | 612 |
//! NOTE: this should also prefill the min/max positions for this joint |
604 | 613 |
//! \param the enum id of a joint |
605 | 614 |
void iCubJointInterface::enable_joint(int e){ |
606 |
//FIXME ADD THIS: |
|
607 |
// enable the amplifier and the pid controller on each joint |
|
608 |
/*for (i = 0; i < nj; i++) { |
|
609 |
amp->enableAmp(i); |
|
610 |
pid->enablePid(i); |
|
611 |
}*/ |
|
612 |
|
|
613 |
|
|
614 |
//set up smooth motion controller |
|
615 |
//step1: set up framerate |
|
616 |
//dev->set_register_blocking(XSC3_REGISTER_PID_RAMP, framerate, true); |
|
617 |
|
|
618 |
//step2: set controllertype: |
|
619 |
//printf("> activating smooth motion control for joint id 0x%02X (%s)\n", motor_id, joint_name.c_str()); |
|
620 |
//dev->set_register_blocking(XSC3_REGISTER_PID_CONTROLLER, XSC3_PROTOCOL_PID_CONTROLLERTYPE_SMOOTH_PLAYBACK, true); |
|
621 |
|
|
622 |
//step3: set pid controller: |
|
623 |
/*if ((e == ID_LIP_LEFT_UPPER) || (e == ID_LIP_LEFT_LOWER) || (e == ID_LIP_CENTER_UPPER) || (e == ID_LIP_CENTER_LOWER) || (e == ID_LIP_RIGHT_UPPER) || (e == ID_LIP_RIGHT_LOWER)){ |
|
624 |
printf("> fixing PID i controller value for smooth motion (FIXME: restore old value!!)\n"); |
|
625 |
dev->set_register_blocking(XSC3_REGISTER_CONST_I, 10, true); |
|
626 |
}*/ |
|
627 |
|
|
628 |
//uint16_t result = dev->get_register_blocking_raw(XSC3_REGISTER_PID_CONTROLLER); |
|
629 |
|
|
630 |
//check if setting pid controllertype was successfull: |
|
631 |
/*if (result != XSC3_PROTOCOL_PID_CONTROLLERTYPE_SMOOTH_PLAYBACK){ |
|
632 |
printf("> failed to set smooth motion controller for joint %s (res=0x%04X)\n",joint_name.c_str(),result); |
|
633 |
exit(1); |
|
634 |
}*/ |
|
635 |
|
|
636 |
//fetch min/max: |
|
637 |
// init_joint(e); |
|
638 |
|
|
639 |
//ok fine, now enable motor: |
|
640 |
//printf("> enabling motor %s\n", joint_name.c_str()); |
|
641 |
//dev->set_register_blocking(XSC3_REGISTER_STATUS, XSC3_PROTOCOL_STATUS_BITMASK_MOTOR_ENABLE, true); |
|
615 |
set_joint_enable_state(e, true); |
|
616 |
} |
|
642 | 617 |
|
618 |
//! shutdown and disable a joint |
|
619 |
//! \param the enum id of a joint |
|
620 |
void iCubJointInterface::disable_joint(int e){ |
|
621 |
set_joint_enable_state(e, false); |
|
643 | 622 |
} |
644 | 623 |
|
645 | 624 |
void iCubJointInterface::store_min_max(IControlLimits *ilimits, int id, int e){ |
... | ... | |
652 | 631 |
//! initialise a joint (set up controller mode etc) |
653 | 632 |
//! \param joint enum |
654 | 633 |
void iCubJointInterface::init_joints(){ |
655 |
store_min_max(ilimits, 0, ID_NECK_TILT);
|
|
656 |
store_min_max(ilimits, 1, ID_NECK_ROLL);
|
|
657 |
store_min_max(ilimits, 2, ID_NECK_PAN);
|
|
658 |
store_min_max(ilimits, 3, ID_EYES_BOTH_UD);
|
|
634 |
store_min_max(ilimits, ICUB_ID_NECK_TILT, ID_NECK_TILT);
|
|
635 |
store_min_max(ilimits, ICUB_ID_NECK_ROLL, ID_NECK_ROLL);
|
|
636 |
store_min_max(ilimits, ICUB_ID_NECK_PAN, ID_NECK_PAN);
|
|
637 |
store_min_max(ilimits, ICUB_ID_EYES_BOTH_UD, ID_EYES_BOTH_UD);
|
|
659 | 638 |
|
660 | 639 |
//icub handles eyes differently, we have to set pan angle + vergence |
661 | 640 |
double pan_min, pan_max, vergence_min, vergence_max; |
662 |
ilimits->getLimits(4, &pan_min, &pan_max);
|
|
663 |
ilimits->getLimits(5, &vergence_min, &vergence_max);
|
|
641 |
ilimits->getLimits(ICUB_ID_EYES_PAN, &pan_min, &pan_max);
|
|
642 |
ilimits->getLimits(ICUB_ID_EYES_VERGENCE, &vergence_min, &vergence_max);
|
|
664 | 643 |
|
665 | 644 |
//this is not 100% correct, should be fixed: |
666 | 645 |
joint_min[ID_EYES_LEFT_LR] = pan_min; // - vergence_max/2; |
... | ... | |
695 | 674 |
|
696 | 675 |
|
697 | 676 |
} |
698 |
|
|
699 |
//! shutdown and disable a joint |
|
700 |
//! \param the enum id of a joint |
|
701 |
void iCubJointInterface::disable_joint(int e){ |
|
702 |
/* |
|
703 |
//first: convert humotion enum to our enum: |
|
704 |
int motor_id = convert_enum_to_motorid(e); |
|
705 |
if (motor_id == -1){ |
|
706 |
return; //we are not interested in that data, so we just return here |
|
707 |
} |
|
708 |
|
|
709 |
//fetch device: |
|
710 |
Device *dev = get_device(motor_id); |
|
711 |
printf("> FIXME: ADD DISABLE CODE\n"); |
|
712 |
printf("> FIXME: ADD DISABLE CODE\n"); |
|
713 |
printf("> FIXME: ADD DISABLE CODE\n"); |
|
714 |
printf("> FIXME: ADD DISABLE CODE\n"); |
|
715 |
printf("> FIXME: ADD DISABLE CODE\n"); |
|
716 |
printf("> FIXME: ADD DISABLE CODE\n"); |
|
717 |
printf("> FIXME: ADD DISABLE CODE\n"); |
|
718 |
printf("> FIXME: ADD DISABLE CODE\n"); |
|
719 |
printf("> FIXME: ADD DISABLE CODE\n"); |
|
720 |
*/ |
|
721 |
} |
Also available in: Unified diff